<?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: Ayush Singh Tomar</title>
    <description>The latest articles on DEV Community by Ayush Singh Tomar (@ayushsinghtomar).</description>
    <link>https://dev.to/ayushsinghtomar</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%2F4003437%2Fe65cd1a7-7525-47b6-9667-cabb87f59d40.jpg</url>
      <title>DEV Community: Ayush Singh Tomar</title>
      <link>https://dev.to/ayushsinghtomar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ayushsinghtomar"/>
    <language>en</language>
    <item>
      <title>I built a multi-agent AI system that researches any startup in under 90 seconds</title>
      <dc:creator>Ayush Singh Tomar</dc:creator>
      <pubDate>Thu, 13 Aug 2026 03:30:05 +0000</pubDate>
      <link>https://dev.to/ayushsinghtomar/i-built-a-multi-agent-ai-system-that-researches-any-startup-in-under-90-seconds-iai</link>
      <guid>https://dev.to/ayushsinghtomar/i-built-a-multi-agent-ai-system-that-researches-any-startup-in-under-90-seconds-iai</guid>
      <description>&lt;p&gt;&lt;em&gt;Type a company name. Get funding history, competitors, strengths, risks, and a verdict — written by three AI agents that hand real work off to each other, not one model faking a team. Here's how StartupScope works, and what building it taught me about production LLM systems.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick facts:&lt;/strong&gt; 3 sequential agents · Groq LLaMA 3.3 70B · live web search with fallback · MIT licensed · reports typically land in well under 90 seconds (my last Stripe run finished in 46s)&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Every time I wanted to size up a company — for a job application, a comparison, or just curiosity — I'd end up with 15 tabs open: Crunchbase, TechCrunch, the company's own site, a couple of "top competitors" listicles, LinkedIn. Ten minutes of tab-switching to answer one question: &lt;em&gt;what does this company actually do, and should I care?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;StartupScope&lt;/strong&gt;. Type a company name, and three AI agents research, analyze, and write a structured intelligence report — funding history, business model, competitors, strengths, risks, a verdict. Compare two companies side by side if you want. Most runs finish in under 90 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://startupscope-ai.streamlit.app/" rel="noopener noreferrer"&gt;startupscope-ai.streamlit.app&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/ayush-s-tomar/startupscope" rel="noopener noreferrer"&gt;github.com/ayush-s-tomar/startupscope&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the single-company flow, start to finish:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnmbje4bht0o4miqp82rg.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnmbje4bht0o4miqp82rg.gif" alt="StartupScope researching a single company end-to-end, from input to finished report" width="600" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here's Compare Mode — two full intelligence reports generated and rendered side by side in one run:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff9w9k4x09lyifzimeva3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff9w9k4x09lyifzimeva3.png" alt="StartupScope Compare Mode showing two companies analyzed side-by-side" width="800" height="650"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Why three agents instead of one prompt
&lt;/h2&gt;

&lt;p&gt;My first version was a single LLM call with a big prompt: "research this company and write a report." It worked, technically. It also hallucinated funding numbers with total confidence, blurred "what they do" into "why they're good," and produced the same generic paragraph shape no matter how much real data it had found.&lt;/p&gt;

&lt;p&gt;The fix wasn't a better prompt — it was splitting the job into three roles that pass a shared, structured context object down the line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Input (Company Name)
        │
        ▼
[Agent 1 · Researcher]
Searches the web (Serper → DuckDuckGo fallback),
ranks sources by credibility, writes findings into
shared agent_context
        │
        ▼
[Agent 2 · Analyst]
Reads agent_context, extracts strengths, risks,
market opportunity, and a verdict
        │
        ▼
[Agent 3 · Writer]
Reads the fully-populated agent_context,
formats everything into a clean markdown report
        │
        ▼
Markdown Report (.md) + Structured JSON (.json)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Splitting &lt;strong&gt;finding information&lt;/strong&gt; (Researcher), &lt;strong&gt;judging it&lt;/strong&gt; (Analyst), and &lt;strong&gt;presenting it&lt;/strong&gt; (Writer) into separate CrewAI agents made a bigger quality difference than any prompt tweak I tried. Each agent has one job and a narrow, well-defined output — which made the pipeline far easier to debug too, since I could inspect &lt;code&gt;agent_context&lt;/code&gt; at each handoff instead of reverse-engineering one giant completion.&lt;/p&gt;

&lt;p&gt;Three agents fixed the structure. They didn't, on their own, fix the lying.&lt;/p&gt;




&lt;h2&gt;
  
  
  The hard part: making it not lie
&lt;/h2&gt;

&lt;p&gt;The real problem with a tool like this isn't "can an LLM summarize a company" — it's "can I stop it from confidently inventing a Series B round that never happened."&lt;/p&gt;

&lt;p&gt;A few things mattered more than I expected:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source credibility scoring.&lt;/strong&gt; Search results are ranked by domain trust before the Researcher agent even reads them — Crunchbase, TechCrunch, and Reuters outrank random forum posts and SEO-farm "top 10 competitors" listicles. Garbage in, garbage out is very real when your input is live web search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explicit permission to say "I don't know."&lt;/strong&gt; The agents are instructed to output "Not specified" rather than guess when a field — founding year, exact funding total, HQ — isn't in the source data. It's a small instruction with an outsized effect: it's what separates a tool you can trust from one that just sounds confident either way. Some companies genuinely don't disclose this stuff publicly, and the report should say that plainly instead of papering over the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search fallback.&lt;/strong&gt; Serper is the primary search provider, but free-tier quotas run out and third-party APIs go down. DuckDuckGo is the fallback, so a quota hit doesn't mean a dead app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries with backoff.&lt;/strong&gt; Groq's free tier and Serper's free tier both rate-limit. Instead of the whole run crashing on a transient 429, failed steps retry with exponential backoff. Unglamorous, but it's what separates a demo that works once from a tool people can rely on.&lt;/p&gt;

&lt;p&gt;None of that shows up on screen, though. The next problem was making the 60-plus seconds it takes actually feel trustworthy while it's happening.&lt;/p&gt;




&lt;h2&gt;
  
  
  Killing the silent spinner
&lt;/h2&gt;

&lt;p&gt;Small thing, but it mattered a lot for how the tool &lt;em&gt;felt&lt;/em&gt; to use: early versions had one spinner sitting there for 60–90 seconds with zero feedback. Users — including me — kept refreshing, assuming it had crashed.&lt;/p&gt;

&lt;p&gt;Now every agent streams its status live:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔍 Researcher is searching the web...&lt;br&gt;
📊 Analyst is extracting insights...&lt;br&gt;
✍️ Writer is composing the report...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Watching the three-stage pipeline actually work in real time, instead of staring at a spinner, changed how the tool felt — from "is this broken?" to "oh, it's doing three separate jobs." Same underlying latency, completely different perceived experience. The live step counter ("Step 2/4 · 34s elapsed") does double duty as a built-in performance readout. Nice side effect of building for UX: I get to watch my own regressions happen in real time too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5mwnavsuyfvgx0ta62b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5mwnavsuyfvgx0ta62b.png" alt="StartupScope's Razorpay intelligence report — founded, HQ, team size, funding, strengths, and verdict in one card" width="800" height="688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the experience layer. Underneath it, here's what's actually running the pipeline:&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tech&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent framework&lt;/td&gt;
&lt;td&gt;CrewAI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;td&gt;Groq API (LLaMA 3.3 70B)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web search&lt;/td&gt;
&lt;td&gt;Serper Dev API + DuckDuckGo (fallback)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;Streamlit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;Streamlit Community Cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's the core loop. A few things around the edges make it more than a one-shot demo:&lt;/p&gt;




&lt;h2&gt;
  
  
  What else it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Try an example&lt;/strong&gt; — one-click company buttons (OpenAI, Anthropic, Stripe, Razorpay, Notion) pre-fill the input so you can see a full report before typing anything&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare Mode&lt;/strong&gt; — research two companies in one run, view tabbed or side by side&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report history&lt;/strong&gt; — every past report is saved and browsable from a sidebar&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual export&lt;/strong&gt; — every run saves both a &lt;code&gt;.md&lt;/code&gt; and a structured &lt;code&gt;.json&lt;/code&gt; (typed schema: funding, competitors, strengths, risks, verdict), so the output is usable programmatically, not just readable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI batch mode&lt;/strong&gt; — point it at a CSV of company names and it researches all of them sequentially, with a built-in delay to respect rate limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI on every push&lt;/strong&gt; — GitHub Actions lints with &lt;code&gt;ruff&lt;/code&gt; and byte-compiles every module across Python 3.10–3.12 before anything reaches &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# single company&lt;/span&gt;
python main.py &lt;span class="nt"&gt;--company&lt;/span&gt; Razorpay

&lt;span class="c"&gt;# batch mode from CSV&lt;/span&gt;
python main.py &lt;span class="nt"&gt;--batch&lt;/span&gt; companies.csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of that means it's finished, though.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd still change
&lt;/h2&gt;

&lt;p&gt;Being honest about the rough edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Report quality is only as fresh as Serper/DuckDuckGo results — a funding round announced an hour ago might not surface yet.&lt;/li&gt;
&lt;li&gt;Free-tier rate limits mean heavy back-to-back usage triggers the retry logic and slows things down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is secret; it's in the README's "Known Limitations" section on purpose. A report that's honest about its own gaps is more useful than one that pretends they don't exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Fifteen tabs down to one input box. That was the whole goal — go see if it holds up on a company you actually care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://startupscope-ai.streamlit.app/" rel="noopener noreferrer"&gt;startupscope-ai.streamlit.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/ayush-s-tomar/startupscope" rel="noopener noreferrer"&gt;github.com/ayush-s-tomar/startupscope&lt;/a&gt; — MIT licensed, clone it and run it locally with your own free Groq + Serper keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build multi-agent systems too, I'd genuinely like to hear how you handle agent-to-agent context passing. CrewAI's shared context object worked for me here, but I'm curious what else is out there for keeping a 3+ agent pipeline debuggable instead of a black box — drop your approach in the comments, I'm collecting ideas for the next iteration.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://github.com/ayush-s-tomar" rel="noopener noreferrer"&gt;Ayush Singh Tomar&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Built an AI Agent That Thinks Before It Answers — And Loops Back When It Doesn't Know Enough</title>
      <dc:creator>Ayush Singh Tomar</dc:creator>
      <pubDate>Mon, 10 Aug 2026 03:30:04 +0000</pubDate>
      <link>https://dev.to/ayushsinghtomar/i-built-an-ai-agent-that-thinks-before-it-answers-and-loops-back-when-it-doesnt-know-enough-39o</link>
      <guid>https://dev.to/ayushsinghtomar/i-built-an-ai-agent-that-thinks-before-it-answers-and-loops-back-when-it-doesnt-know-enough-39o</guid>
      <description>&lt;p&gt;Most AI projects look 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;User Input → LLM → Output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not an agent. That's autocomplete with a UI.&lt;/p&gt;

&lt;p&gt;AgentLoop is different. Give it a research topic and it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Checks if it already researched something similar (long-term memory)&lt;/li&gt;
&lt;li&gt;Breaks the topic into targeted sub-questions&lt;/li&gt;
&lt;li&gt;Decides per sub-question whether to call a live web search tool&lt;/li&gt;
&lt;li&gt;Re-reads its own notes and routes itself back into research if it finds gaps&lt;/li&gt;
&lt;li&gt;Only then writes a structured, sourced report&lt;/li&gt;
&lt;li&gt;Saves the run to memory for next time
The loop in the middle — where the agent judges its own output and decides to keep going — that's what makes it genuinely agentic. Not the LLM. The decision-making around it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://agentloop.streamlit.app" rel="noopener noreferrer"&gt;agentloop.streamlit.app&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ayush-s-tomar/agentloop" rel="noopener noreferrer"&gt;github.com/ayush-s-tomar/agentloop&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;p&gt;You type a research topic. The agent runs a 6-node pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Recall] → [Plan] → [Research] → [Reflect] → [Synthesize] → [Persist]
                                      ↑               |
                                      └───────────────┘
                                    (loops back if gaps found)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Recall&lt;/strong&gt; — checks SQLite long-term memory for related past research. If it finds something relevant, it loads those notes as context before planning. Avoids re-researching the same ground twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan&lt;/strong&gt; — the LLM breaks the topic into 3–5 specific sub-questions. Not "tell me about X" — actual targeted questions like "what companies are deploying X in production?" and "what are the failure modes of X?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Research&lt;/strong&gt; — for each sub-question, the LLM decides whether to call the Tavily web search tool. Not every sub-question needs a search — sometimes the answer follows from earlier notes. This is genuine tool-use, not hardcoded search-every-time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reflect&lt;/strong&gt; — the node I'm most proud of. The agent re-reads everything it's gathered and asks: &lt;em&gt;is this complete? Are there gaps?&lt;/em&gt; If yes, it routes back into Research for another loop. If no, it moves forward. Bounded at 3 iterations so it can't loop forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synthesize&lt;/strong&gt; — writes a structured markdown report from all gathered notes and search results. Citations included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persist&lt;/strong&gt; — saves the full run to SQLite: topic, sub-questions, sources, report. Available for recall on future runs.&lt;/p&gt;




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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3eo36t4ihhg3f77tw145.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3eo36t4ihhg3f77tw145.png" alt="AgentLoop LangGraph pipeline diagram — recall, plan, research, reflect, synthesize, persist" width="800" height="918"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The 6-node graph, including the reflect → research loop-back.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyy57m9i5g1n8hd89eyno.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyy57m9i5g1n8hd89eyno.png" alt="AgentLoop Streamlit UI — topic input, live trace, and generated report" width="800" height="530"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Topic input, live trace, and the generated report — all in one Streamlit view.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; LangGraph · Streamlit · Groq (llama-3.1-8b-instant) · Tavily · SQLite · Streamlit Cloud&lt;/p&gt;


&lt;h2&gt;
  
  
  How Each Part Works
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. The Reflect Node — Conditional Loop-Back
&lt;/h3&gt;

&lt;p&gt;This is the decision that separates AgentLoop from a linear pipeline.&lt;/p&gt;

&lt;p&gt;After Research runs, instead of immediately synthesizing, the agent hits the Reflect node:&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;def&lt;/span&gt; &lt;span class="nf"&gt;reflect_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AgentState&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;AgentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;notes&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&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;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;sub_questions&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&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;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub_questions&lt;/span&gt;&lt;span class="sh"&gt;"&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;You researched these questions:
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sub_questions&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

Here are your notes so far:
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

Are there significant gaps? Answer YES or NO, then explain.&lt;/span&gt;&lt;span class="sh"&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;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&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;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Be critical.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;has_gaps&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="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YES&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;iterations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&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;reflect_iterations&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;needs_more_research&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;has_gaps&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;iterations&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;reflect_iterations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;iterations&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LangGraph conditional edge routes based on &lt;code&gt;needs_more_research&lt;/code&gt;:&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="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_conditional_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reflect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;research&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;needs_more_research&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;synthesize&lt;/span&gt;&lt;span class="sh"&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 iteration cap (&lt;code&gt;iterations &amp;lt; 3&lt;/code&gt;) is non-negotiable. Without it, a loop-happy LLM spins forever on ambiguous topics. With it, the worst case is 3 research passes — still far more thorough than one.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Two-Layer Memory
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Short-term (within a run):&lt;/strong&gt; &lt;code&gt;state["notes"]&lt;/code&gt; — a list that accumulates across all Research iterations. Each tool call appends its findings. The Reflect and Synthesize nodes see the full accumulated picture, not just the last search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-term (across runs):&lt;/strong&gt; SQLite with a simple schema — topic, sub-questions, notes, report, timestamp. The Recall node queries this by keyword similarity at the start of every run. Not vector search (that's the next step) — just SQL LIKE matching, which is good enough for a portfolio project and dead simple to reason about.&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;def&lt;/span&gt; &lt;span class="nf"&gt;recall_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AgentState&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;AgentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;topic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&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;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;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;past_runs&lt;/span&gt; &lt;span class="o"&gt;=&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;word&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;words&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="c1"&gt;# top 3 keywords
&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search_by_keyword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;past_runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&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="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;format_past_runs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;past_runs&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="c1"&gt;# most recent 2 matches
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory_context&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Tool-Calling — Decision Per Sub-Question
&lt;/h3&gt;

&lt;p&gt;The LLM doesn't call web search blindly. It receives the tool schema and decides per sub-question:&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="n"&gt;TOOLS&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;name&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;web_search&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;description&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;Search the live web for current information&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;parameters&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;type&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;object&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;properties&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;query&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;type&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;string&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;description&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;Search query&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;required&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;query&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="p"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For factual sub-questions about current events, it calls the tool. For sub-questions it can reason about from existing notes, it doesn't. That's the distinction between a real agent and a search wrapper.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Broke (The Honest Part)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Render Kept Suspending the Live Demo — So I Moved to Streamlit
&lt;/h3&gt;

&lt;p&gt;The original architecture was FastAPI + React, streaming the live trace to the frontend via Server-Sent Events — each node completion fired an SSE event, the UI updated in real time.&lt;/p&gt;

&lt;p&gt;It worked perfectly locally. On Render's free tier, SSE died after 30 seconds — Render closes long-lived HTTP connections on the free plan, and a 6-node agent with multiple web searches takes longer than that. I patched around it with background tasks + polling (the frontend polling &lt;code&gt;/api/research/status/{job_id}&lt;/code&gt; every 2 seconds instead of holding a stream open), and that solved the timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But the deeper problem was the platform itself.&lt;/strong&gt; Render's free web services spin down on inactivity and get suspended monthly, so the live demo link kept going cold between visitors regardless of the polling fix. That's not something you patch — it's the tier's actual model. So I migrated off FastAPI + React entirely onto a single-file Streamlit app, reusing the &lt;code&gt;agent/&lt;/code&gt; and &lt;code&gt;memory/&lt;/code&gt; modules unchanged, deployed to Streamlit Community Cloud. No backend process to suspend, no SSE-vs-polling tradeoff to manage — Streamlit re-runs the script and renders state directly on each interaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; design for the deployment environment, not just localhost — and sometimes the right fix isn't a smarter workaround, it's picking a host whose free-tier model actually matches how the project gets used (an occasional demo click-through, not a service that needs to stay warm).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Model Deprecations — Three Times
&lt;/h3&gt;

&lt;p&gt;Groq deprecated models mid-development, more than once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llama-3.3-70b-versatile&lt;/code&gt; → deprecated&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llama3-70b-8192&lt;/code&gt; → decommissioned&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llama3-groq-70b-8192-tool-use-preview&lt;/code&gt; → tool-calling broken
Each one failed silently or with a cryptic error. Ended up on &lt;code&gt;llama-3.1-8b-instant&lt;/code&gt; — smaller, but stable and actively maintained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; never hardcode a model string. It belongs in an environment variable:&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="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&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_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;llama-3.1-8b-instant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One env var change, no redeployment needed when the next deprecation hits.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Rate Limits Hit During Demo Recording
&lt;/h3&gt;

&lt;p&gt;Groq's free tier is 100k tokens/day. Between development testing, debugging, and demo runs, I exhausted the quota on the same day I tried to record the LinkedIn screenshot. Two hours lost waiting for reset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; keep a separate Groq API key for demos. Never use the dev key for production.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Python 3.14 Broke the Old Render Build
&lt;/h3&gt;

&lt;p&gt;Back when this was still FastAPI on Render, &lt;code&gt;pydantic-core&lt;/code&gt; had no wheel for Python 3.14 — silent build failure, no clear error, just a broken deploy. The fix was one environment variable (&lt;code&gt;PYTHON_VERSION = 3.11.9&lt;/code&gt;) pinning the runtime explicitly instead of trusting the host default.&lt;/p&gt;

&lt;p&gt;It's moot now that the app is Streamlit-only with a much smaller dependency surface, but the underlying lesson traveled with the migration: pin your Python version explicitly on any host, don't trust the default.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replace SQL LIKE matching with vector search.&lt;/strong&gt; Right now the Recall node finds past runs by keyword — it misses semantically similar research with different wording. ChromaDB or Supabase pgvector would fix this. It's the most meaningful upgrade this project needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a second tool.&lt;/strong&gt; Right now the only tool is &lt;code&gt;web_search&lt;/code&gt;. A calculator or structured data lookup would demonstrate the LLM genuinely choosing between tools — not just "search or not." That's a stronger tool-use story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instrument the reflect loop.&lt;/strong&gt; I don't log how often the agent actually loops back vs. goes straight to synthesis. That metric would tell me whether the reflect node is earning its latency or just adding overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Define the LLM interface contract on day one.&lt;/strong&gt; Half my debugging time came from &lt;code&gt;graph.py&lt;/code&gt; and &lt;code&gt;llm.py&lt;/code&gt; making different assumptions about function signatures and return types. One typed contract file written before any agent logic would have caught all of it.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://agentloop.streamlit.app" rel="noopener noreferrer"&gt;agentloop.streamlit.app&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ayush-s-tomar/agentloop" rel="noopener noreferrer"&gt;github.com/ayush-s-tomar/agentloop&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(If you bookmarked the old &lt;code&gt;agentloop.onrender.com&lt;/code&gt; link, it's retired — the app now lives on Streamlit Cloud, for the reasons above.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Type any research topic and watch the pipeline run step by step — the trace panel shows every node as it executes, so you can see exactly when the agent decides to loop back.&lt;/p&gt;

&lt;p&gt;If you're building something similar or have thoughts on the memory architecture, connect with me on &lt;a href="https://linkedin.com/in/ayushsinghtomar" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>langchain</category>
      <category>agenticai</category>
    </item>
    <item>
      <title>My LLM App Was Charging Rent-Controlled Tenants Penthouse Prices — So I Built a Router to Fix It</title>
      <dc:creator>Ayush Singh Tomar</dc:creator>
      <pubDate>Mon, 27 Jul 2026 03:30:15 +0000</pubDate>
      <link>https://dev.to/ayushsinghtomar/my-llm-app-was-charging-rent-controlled-tenants-penthouse-prices-so-i-built-a-router-to-fix-it-38cl</link>
      <guid>https://dev.to/ayushsinghtomar/my-llm-app-was-charging-rent-controlled-tenants-penthouse-prices-so-i-built-a-router-to-fix-it-38cl</guid>
      <description>&lt;p&gt;Every LLM app I've built has the same expensive habit: one model, every request. "What is FastAPI?" and "compare these five architectures in depth" go to the same endpoint, at the same price. That's fine right up until you look at the bill — you're paying big-model rates for questions a much smaller model answers just as well.&lt;/p&gt;

&lt;p&gt;So I built a router that sits in front of two models and decides which one actually earns the request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://llm-cost-router.streamlit.app/" rel="noopener noreferrer"&gt;llm-cost-router.streamlit.app&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/ayush-s-tomar/llm-cost-router" rel="noopener noreferrer"&gt;github.com/ayush-s-tomar/llm-cost-router&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3bxc1fjwna4i64mkrqr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3bxc1fjwna4i64mkrqr.png" alt="LLM Cost Router dashboard showing percentage saved, total requests, and per-query routing decisions" width="800" height="548"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Ask it anything. It classifies the question, then routes it to one of two models on Groq:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Llama 3.1 8B Instant&lt;/strong&gt; — simple, direct questions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Llama 3.3 70B Versatile&lt;/strong&gt; — genuinely complex, multi-part, or open-ended ones&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every request is logged with what it actually cost, and what it &lt;em&gt;would have&lt;/em&gt; cost on the big model every time. The dashboard tracks all of it live — requests, actual spend, hypothetical spend, savings, percentage saved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it decide
&lt;/h2&gt;

&lt;p&gt;Here's the router handling a simple and a complex question back to back, picking a different model each time without being told which to use:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flkntpnumunwfdvvaa2rh.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flkntpnumunwfdvvaa2rh.gif" alt="Demo of the LLM Cost Router routing different queries to different models in real time" width="580" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"What is FastAPI?" → routed to the 8B model → complete, correct answer → &lt;strong&gt;$0.000020&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;"Compare LangGraph and CrewAI in depth, covering architecture trade-offs" → routed to the 70B model → because a shallow answer here wouldn't hold up → &lt;strong&gt;$0.000691&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Same interface. Same person asking. Two different models, chosen automatically, with the price difference sitting right there instead of buried in a monthly invoice.&lt;/p&gt;

&lt;p&gt;Across a mixed session like the one above, the dashboard lands around &lt;strong&gt;33% cheaper&lt;/strong&gt; than defaulting everything to the 70B model. That number isn't fixed — it moves with what you actually ask. A session of simple lookups saves more; a session of genuinely hard questions saves less, because more of them &lt;em&gt;should&lt;/em&gt; be going to the big model. That's the entire design goal: not a flat discount, the right spend per question.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the classifier decides
&lt;/h2&gt;

&lt;p&gt;No embeddings. No cheap pre-classification LLM call — that would defeat the point of a &lt;em&gt;cost&lt;/em&gt; router before it even started. Just a lightweight heuristic in &lt;code&gt;router_logic.py&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query length — long queries default to the big model&lt;/li&gt;
&lt;li&gt;Complexity keywords — "compare," "analyze," "design," "trade-off," "step by step"&lt;/li&gt;
&lt;li&gt;Simple-query patterns — "what is," "who is," "define," "list"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It won't catch everything — a short question that's secretly hard can slip through — but it costs nothing to run, which is the entire point. An LLM call to decide which LLM to call would eat the savings you're trying to create.&lt;/p&gt;

&lt;p&gt;Here's the actual decision path — first match wins, everything else falls through to the cheap model by default:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzal2l7ff0lm22jsd47bv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzal2l7ff0lm22jsd47bv.png" alt="Router decision flow: incoming query checked against three heuristics, branching to Llama 3.3 70B or Llama 3.1 8B" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No semantic caching — a near-identical question still hits the model every time&lt;/li&gt;
&lt;li&gt;Only two tiers — a mid-size model would give the router more room to be precise instead of picking a side&lt;/li&gt;
&lt;li&gt;The keyword heuristic can misfire on short-but-hard questions; a confidence threshold with a fallback path would help&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this is the bigger lever
&lt;/h2&gt;

&lt;p&gt;Most "cut your AI costs" content is prompt-engineering theater — shave a few tokens, cache a response, feel productive. None of it touches the actual lever. The model you pick is the cost. Everything else is rounding error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React&lt;/strong&gt; frontend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streamlit&lt;/strong&gt; for the live public demo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groq&lt;/strong&gt; for both models (Llama 3.1 8B Instant + Llama 3.3 70B Versatile)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Live demo: &lt;a href="https://llm-cost-router.streamlit.app/" rel="noopener noreferrer"&gt;llm-cost-router.streamlit.app&lt;/a&gt;&lt;br&gt;
Repo: &lt;a href="https://github.com/ayush-s-tomar/llm-cost-router" rel="noopener noreferrer"&gt;github.com/ayush-s-tomar/llm-cost-router&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're running LLM calls at any real volume, a router like this is one of the higher-ROI things you can build — a small amount of logic that pays for itself the first time it downgrades a request.&lt;/p&gt;

&lt;p&gt;What's the one API call in your stack that's been quietly overpaying for a year and nobody's checked?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>fastapi</category>
      <category>python</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Two Bugs That Almost Shipped in My Agentic RAG Assistant</title>
      <dc:creator>Ayush Singh Tomar</dc:creator>
      <pubDate>Mon, 13 Jul 2026 03:30:04 +0000</pubDate>
      <link>https://dev.to/ayushsinghtomar/two-bugs-that-almost-shipped-in-my-agentic-rag-assistant-2fm0</link>
      <guid>https://dev.to/ayushsinghtomar/two-bugs-that-almost-shipped-in-my-agentic-rag-assistant-2fm0</guid>
      <description>&lt;p&gt;I built an agentic RAG assistant — upload a PDF, ask questions, get streamed answers grounded strictly in the document. It uses LangGraph for tool orchestration, Groq for inference, Qdrant Cloud for retrieval, and Streamlit for the UI, and it's self-contained: no separate backend service to keep alive for the live demo. If the answer isn't in the document, it says so instead of guessing.&lt;/p&gt;

&lt;p&gt;It worked. Mostly. Every so often, a stray &lt;code&gt;【source】&lt;/code&gt; tag — an internal citation marker never meant for the user — leaked straight into the answer on screen. Not always. Not predictably. That inconsistency turned out to be the whole puzzle, and chasing it down taught me more about streaming systems than the rest of the project combined.&lt;/p&gt;

&lt;p&gt;That was the first of two bugs that surfaced during demo prep — the second one had nothing to do with streaming and everything to do with where I was storing state. Fixing it changed the deployment architecture entirely, which is why the finished version looks different from what I originally built.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Screenshots below use a fictional test document — not a real product or person.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0s24ii6wgogzgqzxb5h.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0s24ii6wgogzgqzxb5h.gif" alt="Agentic RAG assistant streaming a grounded answer in real time, staying within the uploaded document" width="600" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Watching it decide — the agent retrieves, streams the answer token by token, and stays grounded in the uploaded document throughout.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What the project does
&lt;/h2&gt;

&lt;p&gt;Ask a question, and the agent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Retrieves relevant chunks from a Qdrant Cloud vector store built from your uploaded PDFs&lt;/li&gt;
&lt;li&gt;Answers &lt;strong&gt;only&lt;/strong&gt; from retrieved content — if nothing relevant is found, it says so instead of guessing&lt;/li&gt;
&lt;li&gt;Routes queries between &lt;code&gt;llama-3.1-8b-instant&lt;/code&gt; and &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt; based on complexity, so short factual questions don't pay large-model latency or cost&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A LoRA fine-tuned resume screener also exists as a separate service, callable via &lt;code&gt;screen_resume&lt;/code&gt; — it's not wired into the live demo's default flow, so I'm calling that out here rather than letting someone discover the gap themselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; LangGraph (ReAct-style agent) · Groq running &lt;code&gt;gpt-oss-120b&lt;/code&gt; (primary) and &lt;code&gt;llama-3.1-8b-instant&lt;/code&gt; (routed, low-complexity queries) · Qdrant Cloud for the vector store (originally local Chroma — more on that below) · HuggingFace &lt;code&gt;bge-small-en-v1.5&lt;/code&gt; embeddings · Streamlit frontend, fully self-contained.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxsamsmf7an2hcdmsghh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxsamsmf7an2hcdmsghh.png" alt="Agentic RAG assistant answering questions grounded in an uploaded document, correctly declining to answer when info isn't present" width="800" height="636"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The assistant answering only from the uploaded document — quoting specifics like latency numbers and tech stack directly from the source, and correctly refusing to answer when the document has no relevant info ("What is Arjun's favorite hobby?").&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture — and where each bug lived
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1f7tltapnlfpj9e1e0v8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1f7tltapnlfpj9e1e0v8.png" alt="Agentic RAG request pipeline — user question through the LangGraph agent, Qdrant Cloud retrieval, Groq model routing, and streamed answer" width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A user question goes into the Streamlit app, the LangGraph agent either retrieves from Qdrant Cloud or routes to a small or large Groq model depending on complexity, and the answer streams back. Bug #2 lived at the storage step — it's why Qdrant Cloud replaced local Chroma entirely. Bug #1 lived at the streaming step, one layer downstream.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #1: a marker that only sometimes leaked
&lt;/h2&gt;

&lt;p&gt;The agent tags its retrieval citations internally with a marker like &lt;code&gt;【source】&lt;/code&gt;, which is supposed to get stripped before the answer reaches the user. Simple enough — clean each chunk of streamed text with a regex as it comes in.&lt;/p&gt;

&lt;p&gt;Except sometimes it didn't get stripped.&lt;/p&gt;

&lt;p&gt;The cause: streaming breaks text into arbitrary token chunks, and the marker was getting split across two of them. One chunk would end with &lt;code&gt;【sour&lt;/code&gt;, the next would start with &lt;code&gt;ce】&lt;/code&gt;. Since I was running the regex on each chunk in isolation, neither half ever matched the full pattern — both leaked straight through, untouched.&lt;/p&gt;

&lt;p&gt;The fix wasn't a smarter regex. It was &lt;strong&gt;buffering&lt;/strong&gt;: hold back any text that could be the start of an incomplete marker, and only release it once you're sure you're not sitting mid-tag.&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="n"&gt;_CITATION_ARTIFACT_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;【[^】]*】&lt;/span&gt;&lt;span class="sh"&gt;"&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;stream_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&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;Yield the answer as it streams in, with citation markers
    stripped even when a marker is split across chunk boundaries.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&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;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&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;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;

        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;【&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="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nb"&gt;buffer&lt;/span&gt;
                    &lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;

            &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;】&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="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;

            &lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streaming still feels instant to the user. Nothing incomplete ever reaches them — the buffering adds negligible overhead, invisible in normal use.&lt;/p&gt;

&lt;p&gt;The broader lesson: &lt;strong&gt;streaming systems fail in ways batch systems simply can't.&lt;/strong&gt; A batch-mode version of this same assistant would never have hit this bug — it processes the full response as one string before cleaning it. This bug only existed because I was processing partial state in real time, and for a doc-QA tool, a leaked internal marker is exactly the kind of thing that quietly erodes trust in the answer itself, even when the answer underneath it is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: it felt broken because it was, structurally
&lt;/h2&gt;

&lt;p&gt;Bug #1 was a parsing problem. Bug #2 turned out to be an architecture problem wearing a parsing-problem costume.&lt;/p&gt;

&lt;p&gt;Once the streaming issue was fixed, a different problem showed up during demo prep: every time the backend went idle and spun back down, the app "forgot" everything. Users had to re-upload the same PDF and re-ingest it before asking anything, even though nothing about the workflow suggested that should be necessary.&lt;/p&gt;

&lt;p&gt;The cause here wasn't a code bug — it was an architecture mismatch. At the time, the backend was deployed on Render's free tier, which uses an ephemeral filesystem: anything written to local disk, including the Chroma vector store, gets wiped on every restart or spin-down. Free-tier services spin down automatically after 15 minutes of inactivity, which is basically guaranteed for a portfolio demo that isn't getting constant traffic.&lt;/p&gt;

&lt;p&gt;One giveaway made this obvious in hindsight: a response that came back in &lt;strong&gt;0.86 seconds&lt;/strong&gt;. That's far too fast for a real embed → retrieve → LLM round trip. It wasn't a fast correct answer — it was an empty vector store returning nothing, instantly.&lt;/p&gt;

&lt;p&gt;The fix was to stop treating the vector store as something that lives on a server's local disk at all, and move it to a hosted vector database that persists independently of any backend's lifecycle:&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="c1"&gt;# src/vectorstore.py — shared, persistent vector store client
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_qdrant&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QdrantVectorStore&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_huggingface&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HuggingFaceEmbeddings&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;qdrant_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QdrantClient&lt;/span&gt;

&lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HuggingFaceEmbeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-small-en-v1.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QDRANT_URL&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="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QDRANT_API_KEY&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;def&lt;/span&gt; &lt;span class="nf"&gt;get_vectorstore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collection_name&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="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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;QdrantVectorStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;=&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;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;embeddings&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;Ingestion writes to Qdrant instead of a local Chroma folder, and retrieval reads from the same remote collection — so a restart no longer wipes anything out. Once retrieval no longer depended on local state surviving a restart, the "upload every time" problem disappeared along with it.&lt;/p&gt;

&lt;p&gt;This fix ended up reshaping the whole deployment. With persistence handled by Qdrant Cloud, the Streamlit frontend no longer needed a standalone backend to talk to at all — it calls Groq and Qdrant directly. The FastAPI backend (&lt;code&gt;src/api.py&lt;/code&gt;) still exists in the repo for local development and HTTP access to the agent, but the live demo doesn't depend on it being up. One bug fix turned into a simpler, more resilient architecture than the one I started with.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more thing that broke after the fact: a model deprecation
&lt;/h2&gt;

&lt;p&gt;Not a bug in my code, but worth logging alongside the other two: this project originally ran &lt;code&gt;llama-3.3-70b-versatile&lt;/code&gt;. Groq deprecated that model on 2026-06-17, with a shutdown date of 2026-08-16. The agent is now migrated to &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt; — Groq's recommended replacement, with full tool-calling support and a higher free-tier token budget (200K TPD vs 100K TPD).&lt;/p&gt;

&lt;p&gt;Same lesson as the Groq deprecations I hit on a different project: never assume a hosted model string is permanent. If a production system depends on one, it belongs in a config value, not hardcoded — the day it gets deprecated shouldn't be a surprise.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd tell someone building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't clean streamed text chunk-by-chunk if the pattern you're removing can span chunk boundaries.&lt;/strong&gt; Buffer first, clean second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A suspiciously fast response is a bug signal, not a win.&lt;/strong&gt; If retrieval-augmented generation ever responds faster than a real embed-plus-inference round trip should take, check whether it actually retrieved anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free-tier hosting often means ephemeral disk.&lt;/strong&gt; If your app's state needs to survive restarts, it can't live on the same filesystem as the compute — put it in a database or hosted store that's decoupled from the app's lifecycle. In my case, fixing this also let me drop a backend service entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted model strings aren't permanent.&lt;/strong&gt; Keep them in config, not hardcoded, and expect at least one deprecation on any project that lives long enough.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Known limitations
&lt;/h2&gt;

&lt;p&gt;Being upfront about what isn't polished yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gpt-oss-120b&lt;/code&gt;'s tool-calling via Groq occasionally malforms a function call on ambiguous questions, surfacing as a 500 error — not yet hardened.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;screen_resume&lt;/code&gt; expects a separate deployed service and fails gracefully if it's unreachable; this feature isn't live in the deployed demo.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;route_query&lt;/code&gt;'s complexity gate is a word-count heuristic, not a trained classifier — it will occasionally misroute a short-but-hard question to the cheaper model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it / see the code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/ayush-s-tomar/agentic-rag-research-assistant" rel="noopener noreferrer"&gt;github.com/ayush-s-tomar/agentic-rag-research-assistant&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://agentic-rag-groq.streamlit.app" rel="noopener noreferrer"&gt;agentic-rag-groq.streamlit.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The live demo runs entirely on Streamlit Community Cloud now — no separate backend to wake up, so no cold-start wait on first request.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've hit a bug that only showed up because of streaming, chunking, or an ephemeral filesystem, I'd love to hear about it in the comments.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Stack: LangGraph · Groq (gpt-oss-120b + llama-3.1-8b-instant) · Qdrant Cloud · HuggingFace embeddings · Streamlit&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: #ai #python #rag #buildinpublic&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>langgraph</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>I Got Tired of My Portfolio Looking Like a List of Links. So I Built an MCP Server for It.</title>
      <dc:creator>Ayush Singh Tomar</dc:creator>
      <pubDate>Mon, 06 Jul 2026 03:30:13 +0000</pubDate>
      <link>https://dev.to/ayushsinghtomar/i-got-tired-of-my-portfolio-looking-like-a-list-of-links-so-i-built-an-mcp-server-for-it-440o</link>
      <guid>https://dev.to/ayushsinghtomar/i-got-tired-of-my-portfolio-looking-like-a-list-of-links-so-i-built-an-mcp-server-for-it-440o</guid>
      <description>&lt;p&gt;The obvious fix for "my projects all look similar" is a better README — more screenshots, clearer descriptions, maybe a comparison table. I considered that for about five minutes and decided it was still just a nicer list of links.&lt;/p&gt;

&lt;p&gt;What actually made a portfolio project &lt;em&gt;feel&lt;/em&gt; different was making it something you could talk to instead of read. That's what MCP (Model Context Protocol) is built for — it's the standard that lets AI clients like Claude Desktop call external tools directly, not just process text. So I built a server that exposes my 9 projects as queryable tools instead of static entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is MCP, and why does it matter here
&lt;/h2&gt;

&lt;p&gt;Almost every AI-developer portfolio I've seen is a list of links. Mine now includes something you can actually &lt;em&gt;talk to&lt;/em&gt;. Open Claude Desktop, connect my server, and ask "what has Ayush built with FastAPI?" — it doesn't guess from a cached README, it calls a real tool and answers from structured, live data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;A Python MCP server (&lt;code&gt;FastMCP&lt;/code&gt;, stdio transport) exposing five tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;list_projects&lt;/code&gt;&lt;/strong&gt; — short summary of all 9 projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;get_project_details(project_name)&lt;/code&gt;&lt;/strong&gt; — full stack, GitHub link, demo URL for one project, fuzzy-matched by name&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;search_projects_by_stack(technology)&lt;/code&gt;&lt;/strong&gt; — "show me everything using Groq" or "LangGraph" or "React"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;get_flagship_project&lt;/code&gt;&lt;/strong&gt; — the single best project to look at first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;get_resume_summary&lt;/code&gt;&lt;/strong&gt; — background, target role, core stack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The data itself lives in plain Python dictionaries right now — no database needed for something this size. Each tool is a thin function around that data, decorated with &lt;code&gt;@mcp.tool()&lt;/code&gt;.&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="nd"&gt;@mcp.tool&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;search_projects_by_stack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;technology&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;list&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Find all projects that use a given technology or tool.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;technology&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&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;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&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;stack&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stack&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;github&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;github&lt;/span&gt;&lt;span class="sh"&gt;"&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECTS&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tech&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&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;tech&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stack&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;return&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&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;No projects found using &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;technology&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What actually broke
&lt;/h2&gt;

&lt;p&gt;The concept is simple. The setup had four separate rabbit holes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. MCP Inspector defaults to &lt;code&gt;uv&lt;/code&gt;, not plain Python.&lt;/strong&gt; Running &lt;code&gt;mcp dev server.py&lt;/code&gt; opens a UI that assumes your project uses the &lt;code&gt;uv&lt;/code&gt; package manager by default. If you're on a plain venv + pip setup, the command field needs to be manually changed to &lt;code&gt;python&lt;/code&gt; with &lt;code&gt;server.py&lt;/code&gt; as the argument, or it never connects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Claude Desktop as a Windows Store app uses a different config path entirely.&lt;/strong&gt; I'd installed it via the Microsoft Store first. The standard setup instructions all point to &lt;code&gt;%APPDATA%\Claude\claude_desktop_config.json&lt;/code&gt; — which doesn't exist for the Store version, which stores its config somewhere under &lt;code&gt;AppData\Local\Packages\...&lt;/code&gt; instead. Uninstalling and reinstalling the direct &lt;code&gt;.exe&lt;/code&gt; from claude.ai/download fixed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Two nearly-identical config files.&lt;/strong&gt; Once the real app was installed, there's a config &lt;em&gt;snippet&lt;/em&gt; file in your own project folder (for reference) and the &lt;em&gt;actual&lt;/em&gt; app config file (found via Settings → Developer → Edit Config). They can look identical at a glance. I edited the wrong one twice before I noticed the tab breadcrumb showing which was real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The model doesn't always call your tool just because it's connected.&lt;/strong&gt; My first test — "what projects has Ayush built with FastAPI?" — got answered from Claude's own internal guesswork instead of actually calling my server, and got a detail wrong as a result (called ResumeIQ a FastAPI project; it's Flask). Naming the tool or server explicitly in the prompt ("use the portfolio tool to search for...") forced an actual tool call, which then returned correct data every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing it work
&lt;/h2&gt;

&lt;p&gt;Once connected, this is what a real session looks like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; What's Ayush's flagship project and why?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Claude Desktop shows: "Claude wants to use Get flagship project from portfolio" — Always Allow / Deny)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude:&lt;/strong&gt; Your flagship is &lt;strong&gt;SalesAgent&lt;/strong&gt; — an autonomous B2B sales agent that takes a LinkedIn URL, researches the lead, scores them with an ML model (84/100 accuracy), and drafts a hyper-personalized cold email, all in under 45 seconds. Why it's the flagship: full-stack complexity (LangGraph + FastAPI + React + scikit-learn scoring), not a thin API wrapper around an LLM call...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That permission dialog matters — it's proof this is a real, sandboxed tool call happening live, not a scripted response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/ayush-s-tomar/portfolio-mcp-server" rel="noopener noreferrer"&gt;https://github.com/ayush-s-tomar/portfolio-mcp-server&lt;/a&gt;&lt;br&gt;
Connect it to your own Claude Desktop in under 10 minutes — full setup instructions in the README.&lt;/p&gt;

&lt;p&gt;If you're building an AI-developer portfolio and every project you have looks the same shape as the last one, this is a cheap way to add something structurally different: not another agent, but the plumbing that lets other agents actually use what you've built.&lt;/p&gt;

&lt;p&gt;What would you expose if you built one of these for yourself?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>python</category>
      <category>claude</category>
    </item>
    <item>
      <title>I Got Tired of Writing Cold Emails. So I Built an AI Agent to Do It for Me.</title>
      <dc:creator>Ayush Singh Tomar</dc:creator>
      <pubDate>Mon, 29 Jun 2026 04:22:18 +0000</pubDate>
      <link>https://dev.to/ayushsinghtomar/i-got-tired-of-writing-cold-emails-so-i-built-an-ai-agent-to-do-it-for-me-2m4h</link>
      <guid>https://dev.to/ayushsinghtomar/i-got-tired-of-writing-cold-emails-so-i-built-an-ai-agent-to-do-it-for-me-2m4h</guid>
      <description>&lt;p&gt;B2B sales reps spend hours researching a single lead — reading LinkedIn profiles, Googling the company, checking for recent news, then writing a personalized email that doesn't sound like a template. Most of that work is repetitive pattern-matching, not judgment. I wanted to see if an agent could do it better, faster, and without the copy-paste.&lt;/p&gt;

&lt;p&gt;The result is &lt;strong&gt;SalesAgent&lt;/strong&gt; — paste a LinkedIn URL, get a researched lead profile, an ML-based score (0–100), and a hyper-personalized cold email. End to end in under 45 seconds. No templates. No manual research. Just paste and go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://salesagent-ai.streamlit.app/" rel="noopener noreferrer"&gt;salesagent-ai.streamlit.app&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ayush-s-tomar/salesagent" rel="noopener noreferrer"&gt;github.com/ayush-s-tomar/salesagent&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;You paste a LinkedIn profile URL into the frontend&lt;/li&gt;
&lt;li&gt;A LangGraph agent kicks off&lt;/li&gt;
&lt;li&gt;The agent runs live Tavily web searches to research the lead and their company&lt;/li&gt;
&lt;li&gt;A scikit-learn model scores the lead 0–100 based on six signals&lt;/li&gt;
&lt;li&gt;Groq's LLaMA generates a personalized cold email referencing real company events&lt;/li&gt;
&lt;li&gt;You get: lead summary, score with breakdown, and a ready-to-send email&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what it looks like in action — I ran it on Satya Nadella's LinkedIn profile. It found real Microsoft Build 2026 keynote news and referenced it directly in the email:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3aksd6baudqen8n0vk6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3aksd6baudqen8n0vk6.png" alt="SalesAgent running on Satya Nadella's LinkedIn profile" width="800" height="566"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Subject:&lt;/strong&gt; Congrats on Build 2026&lt;/p&gt;

&lt;p&gt;Satya, the Microsoft Build 2026 keynote on June 2–3 featured the native Windows AI agent rollout and an expanded Copilot runtime — a strong signal of where Microsoft is taking the platform.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's not a template. The agent found that news in real time and wrote around it — no preamble, no filler. (More on how I got it to write like this below.)&lt;/p&gt;


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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx9rhe1kim63yext3pdhq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx9rhe1kim63yext3pdhq.png" alt="SalesAgent Architecture Diagram" width="800" height="753"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three nodes. Each one enriches the context for the next. The scoring node doesn't call an LLM — it runs a trained ML model, which is faster and more deterministic for a classification task like this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; LangGraph · FastAPI · React · scikit-learn · Groq LLaMA 3.1 · Tavily · Streamlit Community Cloud&lt;/p&gt;


&lt;h2&gt;
  
  
  How Each Part Works
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Research Node — Tavily + LangGraph
&lt;/h3&gt;

&lt;p&gt;The agent calls Tavily's search API twice per lead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Search 1:&lt;/strong&gt; &lt;code&gt;"{name} {company} LinkedIn"&lt;/code&gt; — pulls profile signals (title, summary, skills)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search 2:&lt;/strong&gt; &lt;code&gt;"{company} news funding jobs 2024"&lt;/code&gt; — checks for recent company activity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tavily returns structured results with titles, URLs, and content snippets. The LangGraph research node processes these into six binary/numeric signals that feed the scorer:&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="n"&gt;signals&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;has_company&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# Is company name known?
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;has_title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# Is job title known?
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skills_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# Number of skills (0–15)
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;has_summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# Does profile have a summary?
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;has_news&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;# Did Tavily find company news?
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;has_jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;# Did Tavily find job postings?
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;has_news&lt;/code&gt; and &lt;code&gt;has_jobs&lt;/code&gt; are the most valuable signals — they tell you whether the company is active and growing right now. That matters more than whether a LinkedIn summary exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Scoring Node — scikit-learn
&lt;/h3&gt;

&lt;p&gt;The scorer uses a &lt;strong&gt;Gradient Boosting Classifier&lt;/strong&gt; trained on 500 synthetic samples generated with numpy. Labels were assigned using a weighted formula:&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="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;has_news&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;   &lt;span class="c1"&gt;# Company is in the news = hot lead
&lt;/span&gt;    &lt;span class="n"&gt;has_jobs&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;   &lt;span class="c1"&gt;# Hiring = growing, budget exists
&lt;/span&gt;    &lt;span class="n"&gt;has_title&lt;/span&gt;   &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;   &lt;span class="c1"&gt;# We know who we're targeting
&lt;/span&gt;    &lt;span class="n"&gt;has_summary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;   &lt;span class="c1"&gt;# They invest in their profile
&lt;/span&gt;    &lt;span class="n"&gt;skills_count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;  &lt;span class="c1"&gt;# Proxy for profile completeness
&lt;/span&gt;    &lt;span class="n"&gt;has_company&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;     &lt;span class="c1"&gt;# Basic data quality check
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why ML instead of just an LLM scoring the lead? Two reasons: speed and determinism. An LLM call adds 2–3 seconds and gives you a different score every run. A trained classifier runs in milliseconds and gives you the same score for the same inputs every time — which matters when you're building something people actually use.&lt;/p&gt;

&lt;p&gt;In production, you'd retrain on real CRM data — won vs lost deals — with richer features like funding stage, company size, industry vertical, and email response rate. But for a portfolio project with no CRM access, synthetic training with domain-informed weights gets you a working, explainable scorer.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Email Generation Node — Groq + LLaMA
&lt;/h3&gt;

&lt;p&gt;The email node takes the full lead context — name, title, company, recent news, job postings — and injects it into a structured prompt. Getting this right took a second pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first version technically worked but still sounded like AI.&lt;/strong&gt; It pulled real company news in, but the LLM kept wrapping those facts in filler:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I hope this email finds you well. I was thrilled to see the exciting news about your company's recent innovative advancements..."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Real news, buried in template language. A recipient skims the first line, sees "I hope this finds you well," and stops reading — even if paragraph two has something genuinely specific to say.&lt;/p&gt;

&lt;p&gt;So I went back into the prompt and made one change: forbid the filler outright, and force the email to open with a fact, not a greeting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;System: You are an expert B2B sales copywriter. Never use generic openers
like "I hope this email finds you well" or "I was excited to see." Open
with a specific fact about the company. Reference only real information
provided — no invented details.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference was immediate. Same research, same lead, same model — just a tighter constraint on how it's allowed to start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; "I hope this email finds you well. I was excited to see..."&lt;br&gt;
&lt;strong&gt;After:&lt;/strong&gt; "Congrats on the Build 2026 keynote on June 2 — the native Windows AI agent rollout and the expanded Copilot runtime were a strong signal of where Microsoft is taking the platform."&lt;/p&gt;

&lt;p&gt;No preamble. It leads with the thing that makes the email worth reading.&lt;/p&gt;

&lt;p&gt;Running it again on the same lead (Satya Nadella, against real Microsoft Build 2026 news) pushed the lead score from 84/100 to 90/100 — not because the scoring model changed, but because a fact-first email correlates with a more complete signal set (news found, jobs found, title confirmed) that the scorer weighs directly. Worth being honest about what that means: the scorer is still trained on synthetic data, not real won/lost deals, so it's a good relative signal between two runs of the same agent — not a claim that 90/100 means "this lead will convert."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidvm8lpn0ro9x8vklc3k.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidvm8lpn0ro9x8vklc3k.gif" alt="SalesAgent full pipeline demo" width="600" height="417"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  What Broke (The Honest Part)
&lt;/h2&gt;

&lt;p&gt;This is where I spent most of my time. Real projects break in ways tutorials never show you.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Groq Model Deprecations — Three Times
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;llama-3.3-70b-versatile&lt;/code&gt; failed. Switched to &lt;code&gt;llama3-70b-8192&lt;/code&gt;. That was decommissioned. Tried &lt;code&gt;llama3-groq-70b-8192-tool-use-preview&lt;/code&gt; — tool-calling didn't work properly. Ended up on &lt;code&gt;llama-3.1-8b-instant&lt;/code&gt;, which is smaller but stable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; never hardcode a model string. In a production system, this belongs in a config file or environment variable so you can swap it without touching code.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Tool-Calling Schema Bug — 400 Failed Generation
&lt;/h3&gt;

&lt;p&gt;Groq was rejecting my tool schemas with a &lt;code&gt;failed_generation&lt;/code&gt; 400 error. After multiple attempts to isolate it, the issue was that I was passing &lt;code&gt;input_schema&lt;/code&gt; directly instead of extracting &lt;code&gt;properties&lt;/code&gt; and &lt;code&gt;required&lt;/code&gt; separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong:&lt;/strong&gt;&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_schema&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Right:&lt;/strong&gt;&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parameters&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;type&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;object&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;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_schema&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&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;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;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_schema&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;required&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This took longer than it should have because the error message (&lt;code&gt;failed_generation&lt;/code&gt;) gave no hint about the schema structure. If you're hitting this — check your tool schema first.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Interface Mismatch Between graph.py and llm.py
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;graph.py&lt;/code&gt; was calling &lt;code&gt;run_with_tools(prompt=..., system=...)&lt;/code&gt; and expecting a &lt;code&gt;(text, tool_log)&lt;/code&gt; tuple back. &lt;code&gt;llm.py&lt;/code&gt; was written to accept &lt;code&gt;messages=[]&lt;/code&gt; and return a dict. Classic interface mismatch between two files written in isolation.&lt;/p&gt;

&lt;p&gt;Every bug from this — the &lt;code&gt;prompt&lt;/code&gt; vs &lt;code&gt;messages&lt;/code&gt; confusion, the &lt;code&gt;system&lt;/code&gt; kwarg error, the tuple vs dict return type — cost me hours of debugging that a typed interface contract would have caught in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Python 3.14 on Render
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pydantic-core&lt;/code&gt; failed to build because no wheel exists for Python 3.14. Fix: force &lt;code&gt;PYTHON_VERSION=3.11.9&lt;/code&gt; in the environment variables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're deploying to a platform like Render: always pin your Python version explicitly.&lt;/strong&gt; Don't trust the platform default.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The Cached ML Model That Wouldn't Let Go
&lt;/h3&gt;

&lt;p&gt;After I rebalanced the scoring weights to favor the fact-first signals, the lead score stayed frozen — every run, no matter what I changed in the prompt or the code. I checked the prompt, checked the API response, checked the signal extraction. All correct. The score still didn't move.&lt;/p&gt;

&lt;p&gt;The actual culprit: &lt;code&gt;model.pkl&lt;/code&gt;, the trained scorer, was cached on disk from a previous deploy and never got retrained when the weighting logic changed underneath it. The code was right. The model on disk was stale. Nothing in the logs said so — it just quietly kept scoring against old assumptions.&lt;/p&gt;

&lt;p&gt;Fix was one line in the build command: &lt;code&gt;rm -f ml/model.pkl&lt;/code&gt; before every deploy, forcing a retrain from scratch each time. Cheap fix, expensive to find — because a stale artifact doesn't throw an error, it just gives you a plausible-looking wrong answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The Render Free Tier Got Suspended
&lt;/h3&gt;

&lt;p&gt;Mid-project, my backend host (Render) suspended the free-tier deployment. No warning I saw in time — just a "This service has been suspended" page where the API docs used to be.&lt;/p&gt;

&lt;p&gt;I didn't fight it. I pulled every reference to the old backend URL out of the README, replaced the curl example with a localhost version so anyone cloning the repo can still test the endpoint locally, and added an explicit line to the README's Known Limitations section rather than leaving a dead link for someone to discover on their own:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Backend is not hosted live — the free-tier Render deployment was suspended, so the API is not reachable at a public URL right now. Run it locally with the steps above."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I ended up migrating the live demo to Streamlit Community Cloud instead, which is why the link above looks different from earlier screenshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; on a portfolio project, a dead link someone finds themselves is worse than an honest sentence explaining why it's dead.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Demo Assets and Docs Drifted From the Code
&lt;/h3&gt;

&lt;p&gt;Between the screenshot, the GIF, and the README's "Demo Output" text block, I'd updated the code but not all three assets — so the README was showing an 84/100 score with old email copy while the actual screenshot showed 90/100 with the new fact-first output. Small inconsistency, but exactly the kind of thing a careful reader (or a hiring manager) notices.&lt;/p&gt;

&lt;p&gt;Separately, the README's file tree still listed &lt;code&gt;demo.mp4&lt;/code&gt; as "optional" — leftover from before I'd actually recorded and embedded it — and didn't mention &lt;code&gt;demo.gif&lt;/code&gt; at all, even though both were live in &lt;code&gt;docs/&lt;/code&gt;. Nothing broken, just stale comments describing a folder that had since changed shape.&lt;/p&gt;

&lt;p&gt;Fixed both by treating all demo assets as one unit: screenshot, GIF, and the "Demo Output" text get regenerated and reviewed together, not independently.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Define the LLM interface contract on day one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The biggest source of bugs was &lt;code&gt;graph.py&lt;/code&gt; and &lt;code&gt;llm.py&lt;/code&gt; making different assumptions about function signatures, return types, and argument names — and those assumptions were never written down anywhere.&lt;/p&gt;

&lt;p&gt;If I rebuilt SalesAgent today, the first file I'd create:&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="c1"&gt;# contracts.py — written before any other code
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_with_tools&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&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="nb"&gt;str&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;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;Run LLM with tool-calling. Returns (response_text, tool_call_log).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&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;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;system&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Simple chat completion. Returns response string.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One typed file, agreed upfront. Every bug from the interface mismatch would have been caught before a single line of agent logic was written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Walk through the whole project as a stranger would before sharing it.&lt;/strong&gt; Click every link, rerun the pipeline end to end, read every README claim against what's actually running. None of the bugs above were algorithm problems — they were discipline problems: a prompt that quietly regressed into filler, a model artifact that silently went stale, docs that drifted from the code. This is the cheapest QA pass available, and it's the one that catches stale caches and dead links before someone else does.&lt;/p&gt;

&lt;p&gt;Beyond that — in a production version, I'd replace synthetic training data with &lt;strong&gt;real CRM data&lt;/strong&gt; (won/lost deals) and add &lt;strong&gt;email open tracking&lt;/strong&gt; to close the feedback loop, so the scorer retrains on actual outcomes instead of assumed weights.&lt;/p&gt;




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

&lt;p&gt;Paste any LinkedIn URL into the live demo and watch the full research → score → draft pipeline run in real time. The email quality scales with how much public news exists about the company — well-covered companies get sharper, more specific emails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://salesagent-ai.streamlit.app/" rel="noopener noreferrer"&gt;salesagent-ai.streamlit.app&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ayush-s-tomar/salesagent" rel="noopener noreferrer"&gt;github.com/ayush-s-tomar/salesagent&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're building something similar, or have hit similar prompt-drift, stale-cache, or deploy-suspension issues on your own projects, I'd genuinely like to hear about it — connect with me on &lt;a href="https://linkedin.com/in/ayushsinghtomar" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Stack: LangGraph · FastAPI · React · scikit-learn · Groq LLaMA 3.1 · Tavily · Streamlit Community Cloud&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: #ai #python #machinelearning #langchain #buildinpublic&lt;/em&gt;&lt;/p&gt;

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