<?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: Sayam Jain</title>
    <description>The latest articles on DEV Community by Sayam Jain (@sayam_jain).</description>
    <link>https://dev.to/sayam_jain</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%2F4238939%2F134ddc28-6074-4b73-894f-b318a5edd550.jpg</url>
      <title>DEV Community: Sayam Jain</title>
      <link>https://dev.to/sayam_jain</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sayam_jain"/>
    <language>en</language>
    <item>
      <title>Not every AI feature needs an agent</title>
      <dc:creator>Sayam Jain</dc:creator>
      <pubDate>Wed, 11 Nov 2026 12:58:57 +0000</pubDate>
      <link>https://dev.to/sayam_jain/not-every-ai-feature-needs-an-agent-42d3</link>
      <guid>https://dev.to/sayam_jain/not-every-ai-feature-needs-an-agent-42d3</guid>
      <description>&lt;p&gt;These days almost every LLM integration gets called an agent. Add a model, give it two tools, and suddenly it's "agentic".&lt;/p&gt;

&lt;p&gt;I build a WhatsApp assistant for myself, and it is deliberately not an agent. The design is a workflow: plain code decides what the model sees, the model gets one narrow job, and code checks its answer. That choice makes the behaviour easier for me to inspect.&lt;/p&gt;

&lt;p&gt;Here's how I think about the difference, and the same task built both ways so you can see what changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflow or agent, in one line each
&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%2F7l8luw7a7lsw1t254ci3.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%2F7l8luw7a7lsw1t254ci3.png" alt="Fig. 1: Who decides the steps. Five shapes an AI feature can take, grouped by who decides the steps. Your code decides in a single model call, in retrieval then generation, and in a workflow. The model decides in an agent and in several agents." width="800" height="675"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.anthropic.com/engineering/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic's foundational guide to building effective agents&lt;/a&gt;, published in December 2024, draws a useful distinction. In a &lt;strong&gt;workflow&lt;/strong&gt;, the model and tools run through code paths you define. In an &lt;strong&gt;agent&lt;/strong&gt;, the model directs its own next steps and decides which tools to use.&lt;/p&gt;

&lt;p&gt;The guide's practical advice is to start with the simplest approach and add complexity only when the problem needs it. Sometimes that means no agent at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five shapes an AI feature can take
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Who controls the steps&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single model call&lt;/td&gt;
&lt;td&gt;Your code&lt;/td&gt;
&lt;td&gt;Classifying, summarising, rewriting or extracting from text you already have&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval, then generation&lt;/td&gt;
&lt;td&gt;Your code&lt;/td&gt;
&lt;td&gt;Finding relevant document passages before asking the model to answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow&lt;/td&gt;
&lt;td&gt;Your code&lt;/td&gt;
&lt;td&gt;A known sequence: rank, call the model, validate and apply business rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;Mostly the model, within code defined tools and limits&lt;/td&gt;
&lt;td&gt;Problems where the next step depends on what the model discovers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several agents&lt;/td&gt;
&lt;td&gt;Models work across separate roles, within an orchestration layer&lt;/td&gt;
&lt;td&gt;Work that genuinely splits into independent jobs with clear boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These aren't levels you have to climb. They're different ways of organising an AI feature. Handing more decisions to the model can mean more requests, more accumulated context and a less predictable run. It depends on the design; a model can also return more than one tool call in a single response.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow behind my WhatsApp assistant
&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%2Fzmfcm7augj8h90ny2sjt.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%2Fzmfcm7augj8h90ny2sjt.png" alt="Fig. 2: How my WhatsApp bot finds what needs me. Every chat is indexed on my machine. Plain code picks a short list, ranked by points when the switch is on and from unread chats otherwise. One model call reads that list and returns findings with quotes, plain code checks every quote against its message, and only verified findings reach Focus." width="800" height="751"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Message Yourself OS reads my WhatsApp and tells me who's waiting on me and what I promised. Every chat is indexed on my machine. In the ranked mode I'm testing, the flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;every chat, indexed on my machine
        |
        v
plain code scores each chat on simple signals
(unread messages, they wrote last, a message ending in "?",
 my last message using words like "I'll" or "I will",
 activity today; muted chats and quiet groups lose points)
        |
        v
12 points to qualify, at most 50 chats, 40,000 token budget
        |
        v
one model call: what's waiting on me, what I promised,
each with the exact quote
        |
        v
plain code checks every quote against its message
        |
        v
!today: checked findings go into Focus, the rest are marked to verify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail matters: the ranked mode is behind a switch. Until it passes a gate of seven reviewed days of real use, the assistant uses unread chats instead. The diagram and comparison below show the ranked workflow I'm testing, not a claim that the new ranked brief is already the default.&lt;/p&gt;

&lt;p&gt;The model never decides which chats to open. Code does, with rules I can read and explain. Those rules don't understand meaning; they look for signals like a question mark or the words "I'll" and "I will". Understanding whether a message is really a question for me or a promise is the model's job. Code then checks the evidence and sender.&lt;/p&gt;

&lt;p&gt;One thing to be clear about, because this is WhatsApp: indexing and scoring happen on my machine, but the messages in the chats that make the shortlist do go to the model provider. The bot now uses Groq, with OpenAI as fallback. Local indexing means the model sees a shortlist, not that it sees nothing.&lt;/p&gt;

&lt;p&gt;I should also be honest about the plan the bot used. Until November 2026 it ran on Gemini's unpaid quota. Google's terms say content sent through unpaid services can be used to improve Google products and may be reviewed by people, and they ask you not to submit sensitive, confidential or personal information. My shortlist contains other people's messages, so I've switched the bot's default provider to Groq. Groq's documentation says it doesn't keep inference inputs or outputs by default, may keep logs for up to 30 days when it investigates reliability or abuse, and lets every customer turn on Zero Data Retention to stop even that, which is how the bot's account is set. Read the terms for every provider in your own fallback chain before sending real messages; mine still has OpenAI as a fallback. The public demo below uses only made up chats. &lt;a href="https://ai.google.dev/gemini-api/terms" rel="noopener noreferrer"&gt;Google's Gemini API terms&lt;/a&gt; and &lt;a href="https://console.groq.com/docs/your-data" rel="noopener noreferrer"&gt;Groq's data controls&lt;/a&gt; explain the details.&lt;/p&gt;

&lt;p&gt;Could an agent do this? Sure. Give it &lt;code&gt;list_chats&lt;/code&gt; and &lt;code&gt;read_chat&lt;/code&gt; and let it figure things out. But I already know the steps. For this task, asking the model to rediscover them each morning adds calls and more places for the run to stop or go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same task, built both ways
&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%2Fjk409ckua9k17l06uawo.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%2Fjk409ckua9k17l06uawo.png" alt="Fig. 3: The same task, built both ways. Both start from the same twelve made up chats. The workflow ranks them in code, makes one model call on the seven that qualify, checks every quote and finds both promises. The agent lists the chats, reads the ones where someone asked a question, decides to finish, passes the same check and finds neither promise, because it never opened Arjun's or Neha's chats." width="800" height="1087"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To make the difference concrete I built a small demo: twelve made up chats, and the same triage task done twice. The code, prompts, settings and raw traces are on &lt;a href="https://github.com/sayamjn/shipping-with-ai/tree/main/01-agent-or-workflow" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The workflow&lt;/strong&gt; ranks chats with the same point rules, sends only the ones that qualify, makes one model call with a JSON schema, and checks the quotes in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pickCandidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&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="n"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&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="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;jsonSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;findingSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyFindings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseFindings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The agent&lt;/strong&gt; gets three tools (&lt;code&gt;list_chats&lt;/code&gt;, &lt;code&gt;read_chat&lt;/code&gt;, &lt;code&gt;finish&lt;/code&gt;) and a loop. It decides what to read, one step at a time, until it calls &lt;code&gt;finish&lt;/code&gt; or hits 15 steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;step&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="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maxSteps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;step&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&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="n"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;agentTools&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toolCalls&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&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;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"finish"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;verifyFindings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;runTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&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;Both end with the same check, so the comparison is fair in one important sense: same chats, same model and same rules for checking evidence. For each finding, the check confirms that the quote appears in the named message and that the sender matches the claim: a promise has to be a message I sent; "waiting on me" has to be a message someone else sent. That verifies evidence and attribution. It doesn't prove the model understood the meaning correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the traces showed
&lt;/h2&gt;

&lt;p&gt;I ran the comparison three times on 10 November 2026, on &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt; through Groq. I started with &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, but my AI Studio project showed a daily limit of 20 requests for that model. I kept the Gemini run separate from the three Groq runs rather than mixing the numbers. It is in the repo too and showed the same pattern.&lt;/p&gt;

&lt;p&gt;These are the three Groq runs. Ranges go from the lowest to the highest run; findings show each run's result.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model calls&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5 to 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools run (&lt;code&gt;finish&lt;/code&gt; not counted)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4 to 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input tokens, all calls added up&lt;/td&gt;
&lt;td&gt;878&lt;/td&gt;
&lt;td&gt;3,633 to 4,695&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visible output tokens, reasoning excluded&lt;/td&gt;
&lt;td&gt;299 to 503&lt;/td&gt;
&lt;td&gt;377 to 464&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thinking tokens&lt;/td&gt;
&lt;td&gt;435 to 723&lt;/td&gt;
&lt;td&gt;613 to 1,029&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model time, not counting waits&lt;/td&gt;
&lt;td&gt;2.7 to 3.5 s&lt;/td&gt;
&lt;td&gt;5.9 to 8.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Waits after an HTTP 429 response&lt;/td&gt;
&lt;td&gt;0 to 10 s&lt;/td&gt;
&lt;td&gt;0 to 29 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Findings that passed the evidence check&lt;/td&gt;
&lt;td&gt;8, 7, 7&lt;/td&gt;
&lt;td&gt;4, 5, 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Promises found, out of 2&lt;/td&gt;
&lt;td&gt;2 every run&lt;/td&gt;
&lt;td&gt;0 every run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Visible output tokens and thinking tokens are separate rows in these traces. Groq reports thinking as part of completion tokens; the demo subtracts it to show visible output and reasoning separately. Add the two rows to get the completion token total reported by the provider.&lt;/p&gt;

&lt;p&gt;This is three runs of two implementations on one synthetic dataset. It isn't a benchmark. A better agent prompt, different stopping conditions, another model or another task could change the result. Treat these as a few honest data points, not a law.&lt;/p&gt;

&lt;p&gt;In this implementation, each agent step makes another model request and sends the conversation so far again. That is why its input token count grows as it opens chats, while the workflow sends its shortlist once.&lt;/p&gt;

&lt;p&gt;Added up across its calls, the agent's input came to about 4.1 to 5.3 times the workflow's for the same twelve chats. That number is cumulative across requests, not one large context window. Every request also counts against the provider's rate limits. The demo waits only when a provider returns HTTP 429, uses the delay the provider supplies and records it. In these back to back Groq runs, the agent waited up to 29 seconds; the workflow waited once, for 10 seconds. In the Gemini run, my project showed a five requests per minute limit for that model, and the agent's sixth request waited 94 seconds. Those are the limits I saw on my accounts that day, not fixed limits for everyone. Groq's documentation explains that its limits vary by model and account: &lt;a href="https://console.groq.com/docs/rate-limits" rel="noopener noreferrer"&gt;Groq rate limits&lt;/a&gt;. Google's quota documentation works the same way: &lt;a href="https://ai.google.dev/gemini-api/docs/rate-limits" rel="noopener noreferrer"&gt;Gemini API rate limits&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The bigger surprise was what it found. In every run the agent listed the chats, opened Riya, Mom, the team standup and usually the recruiter, all chats where someone asked me something, and then decided it was done. It wasn't forced to read every chat. It could call &lt;code&gt;finish&lt;/code&gt; when it thought it had enough, within the 15 step limit. That's the decision I had handed to the model, and it stopped before reaching the two chats that mattered. It never opened Arjun's or Neha's chats, where I had made promises. So it missed both promises in all three Groq runs and in the Gemini run. On Gemini it also labelled the standup message "can you share the deploy notes before 4?" as a promise I made. The quote check caught that because the message wasn't sent by me.&lt;/p&gt;

&lt;p&gt;The workflow found both promises in every run because my ranking gives my last message containing "I'll" or "I will" the same 14 points as a message ending in a question mark. Both chats qualified, so the model saw them. The rule is in code; it doesn't depend on the model remembering to look.&lt;/p&gt;

&lt;p&gt;The check has limits too. In one run the workflow listed Arjun's "Bro the API docs link is broken" as waiting on me. The quote is real and Arjun sent it, so it passed the evidence check even though it isn't really a question. Code can check evidence; it can't check judgement.&lt;/p&gt;

&lt;p&gt;Aur yahi interesting part hai. The agent isn't wrong for every problem. It was the wrong fit for this one: it spent model calls deciding which chats to inspect even though my application already had a rule for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this experiment doesn't prove
&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%2Flywixtouicjqe74bezj8.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%2Flywixtouicjqe74bezj8.png" alt="Fig. 4: Where each one fails. A workflow fails in its rules: a promise written as kal tak bhej dunga earns no promise points, so that chat may never reach the model. An agent fails in its choices: it decides when it has read enough, stops early and never opens the chats that mattered." width="800" height="726"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A workflow can fail too. If my scoring rules are wrong, a chat that matters never reaches the model, and nothing downstream can bring it back. Someone who writes "kal tak bhej dunga" instead of "I'll send it tomorrow" scores no promise points in my rules. An agent can adapt to things my rules never anticipated.&lt;/p&gt;

&lt;p&gt;That's the real tradeoff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Workflow:&lt;/strong&gt; you encode more of your assumptions in code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent:&lt;/strong&gt; you hand more decisions to the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither removes failure. They move where the failure happens. For my inbox I'd rather have failures I can find by reading my own rules than failures I have to dig out of a trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the difference really shows up
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model calls&lt;/td&gt;
&lt;td&gt;Fixed for this design: one&lt;/td&gt;
&lt;td&gt;One per step in this loop; the count depends on the run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens&lt;/td&gt;
&lt;td&gt;Predictable within the input budget&lt;/td&gt;
&lt;td&gt;Grow as the conversation is sent again on each step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control flow&lt;/td&gt;
&lt;td&gt;Defined by code&lt;/td&gt;
&lt;td&gt;Directed by the model within the tools and limits you expose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next action&lt;/td&gt;
&lt;td&gt;Defined by code&lt;/td&gt;
&lt;td&gt;Chosen at run time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model output&lt;/td&gt;
&lt;td&gt;Can vary&lt;/td&gt;
&lt;td&gt;Can vary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Read the code path and model result&lt;/td&gt;
&lt;td&gt;Read the trace and work out why it chose that step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool permissions&lt;/td&gt;
&lt;td&gt;Code calls only what it needs&lt;/td&gt;
&lt;td&gt;The model can call any tool you expose, so expose less&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When it fails&lt;/td&gt;
&lt;td&gt;Easier to locate within a known sequence&lt;/td&gt;
&lt;td&gt;Can stop early or keep going; set step limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human checks&lt;/td&gt;
&lt;td&gt;Easy to place at fixed points&lt;/td&gt;
&lt;td&gt;Put gates around the actions that matter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When I would use an agent
&lt;/h2&gt;

&lt;p&gt;When I can't write the steps down. Investigating an unfamiliar codebase, research where each answer decides the next question, or debugging when I don't know what I'll find. That's where letting the model choose its next move can earn its cost.&lt;/p&gt;

&lt;p&gt;Even then, I'd keep the parts I do know in code: permissions, validation and the final check. The model proposes; my application decides what actually happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building an AI feature
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write down the steps first. If you can, you probably want a workflow.&lt;/li&gt;
&lt;li&gt;Give the model the smallest job only it can do, and let code do the rest.&lt;/li&gt;
&lt;li&gt;Check its output in code, not with another prompt.&lt;/li&gt;
&lt;li&gt;Add agency only where the steps are truly unknown, and put a step limit on it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An agent isn't a better kind of software. Sometimes giving the model fewer decisions is exactly what makes the product good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/engineering/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic: Building effective agents (December 2024)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://console.groq.com/docs/rate-limits" rel="noopener noreferrer"&gt;Groq: Rate limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/gemini-api/docs/rate-limits" rel="noopener noreferrer"&gt;Gemini API: Rate limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/gemini-api/terms" rel="noopener noreferrer"&gt;Gemini API: Additional Terms of Service&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://console.groq.com/docs/your-data" rel="noopener noreferrer"&gt;Groq: Your data in GroqCloud&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.sayamjain.com/projects/message-yourself-os" rel="noopener noreferrer"&gt;Message Yourself OS case study&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sayamjn/shipping-with-ai/tree/main/01-agent-or-workflow" rel="noopener noreferrer"&gt;The demo: code, prompts and traces&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Evals before vibes</title>
      <dc:creator>Sayam Jain</dc:creator>
      <pubDate>Wed, 11 Nov 2026 12:05:22 +0000</pubDate>
      <link>https://dev.to/sayam_jain/evals-before-vibes-5fep</link>
      <guid>https://dev.to/sayam_jain/evals-before-vibes-5fep</guid>
      <description>&lt;p&gt;Here's how a lot of prompt changes ship. You tweak the prompt, try it on three examples you remember, the answers look better, and you ship it. That's vibes.&lt;/p&gt;

&lt;p&gt;I've done it too. Then I spent a few months building checks for my WhatsApp assistant, and the most useful thing they taught me was where my own checks were weaker than I thought. This post is what I built, what it caught, and a small demo that shows the difference between trying a prompt on a few examples and running it against a fixed set.&lt;/p&gt;

&lt;p&gt;There's also a practical reason this is timely. OpenAI's hosted Evals platform became read only for existing users on 31 October 2026, and its dashboard and API are scheduled to shut down on 30 November. If your test cases and graders live there, this is a good time to move them somewhere you can version and run yourself. OpenAI's &lt;a href="https://developers.openai.com/api/docs/deprecations" rel="noopener noreferrer"&gt;deprecation notes&lt;/a&gt; have the current dates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built for my WhatsApp assistant
&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%2F4umhxq0zys3dg9b0b2e6.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%2F4umhxq0zys3dg9b0b2e6.png" alt="Fig. 1: The checks around my WhatsApp bot. Every finding goes from the model's reply through a quote check in plain code, and only verified findings reach Focus. Around that sit 58 test files, an evaluation runner, an AI judge, six labelled scenarios, a gate of seven reviewed days and the !correct command." width="800" height="1017"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Message Yourself OS reads my WhatsApp and tells me who's waiting on me and what I promised. Being wrong is expensive there: a bot that says "you promised this" had better be right. So most of the work went into checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every finding carries its evidence.&lt;/strong&gt; The model has to return the exact quote, the chat and the message it came from. Plain code then fetches that message and checks that the quote is really in it and the time matches. A finding ends up in one of a few states: verified, missing, not found, quote mismatch or time mismatch. Only verified findings reach the Focus list at the top of &lt;code&gt;!today&lt;/code&gt;. Anything else appears under "Needs verification", never as fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;58 test files&lt;/strong&gt; pin the parsers, the evidence check and the rule that unverified items stay out of Focus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A local evaluation runner&lt;/strong&gt; has a fast tier that needs no WhatsApp connection or network, and a full tier that runs commands through a local server and tracks latency and regressions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An AI judge&lt;/strong&gt; scores replies. If the judgement itself fails, the result is reported as "not scored" and is never shown as a number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gate on real use.&lt;/strong&gt; The newer, ranked &lt;code&gt;!today&lt;/code&gt; stays behind a switch until it passes seven reviewed days of real use, with no errors and nothing missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;!correct&lt;/code&gt;&lt;/strong&gt; records why an extraction was wrong, in my own words.&lt;/p&gt;

&lt;h2&gt;
  
  
  What those checks caught, and what they couldn't
&lt;/h2&gt;

&lt;p&gt;The evidence check earned its keep straight away. It's the reason a made up quote can't turn into a reminder. It's also the part I trust most, because it's plain code checking plain facts.&lt;/p&gt;

&lt;p&gt;The rest taught me more by showing me their limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The gate did its job by saying no.&lt;/strong&gt; When I last checked in August 2026, the ranked brief had nine runs over three days and almost none had been reviewed. So it stayed off. Without that gate, I might have switched it on because the replies looked good.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My fast checks had a blind spot.&lt;/strong&gt; They confirmed that all 44 commands were wired up, but the heading checks compared against text I had typed into the test, not the actual output. Useful as a wiring check, not an evaluation of what the command returned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My first AI judge only saw the reply.&lt;/strong&gt; It never saw the messages the reply was about, so it could judge tone and format, not whether the answer was true. I wrote in my own docs that its scores were advisory for exactly that reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I wrote six labelled scenarios and never wired them in.&lt;/strong&gt; An explicit promise, an implied one, a resolved one, group noise, several people and a dead conversation. They sat in a file that no code read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;!correct&lt;/code&gt; records reasons, but nothing reads them back.&lt;/strong&gt; Every correction should become a test case. Mine became a line in a file, but the runner did not use those lines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pattern dikh raha hai? Everything that worked compared the output against something fixed: a real message, a test or a calendar of reviewed days. Everything that didn't was either checking itself or never run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same lesson, in one small demo
&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%2Frhq2b8opwu082s099ya1.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%2Frhq2b8opwu082s099ya1.png" alt="Fig. 2: Three examples against twenty four. On the three hand picked cases both prompts score 3 of 3 in every run, so v1 looks fine. On all 24 labelled cases v1 passes 16 to 18 with 6 to 8 false positives, and v2 passes 22 to 23 with one false positive, the rhetorical question." width="800" height="853"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To show the idea without using my private chats, I built a small eval with 24 synthetic cases. Each case contains one to three chats and the expected findings written down by hand. The set has 17 expected findings in total, and nine cases should return nothing. It covers plain questions, requests without question marks, Hinglish questions and promises, group messages addressed to someone else, requests already answered, promises already kept, rhetorical questions and messages that should not count.&lt;/p&gt;

&lt;p&gt;The task is the same as in my &lt;a href="https://www.sayamjain.com/blog/not-every-ai-feature-needs-an-agent" rel="noopener noreferrer"&gt;last post&lt;/a&gt;: find what's waiting on me and what I promised, with exact quotes. The code, cases, prompts and results are in the &lt;a href="https://github.com/sayamjn/shipping-with-ai/tree/main/02-evals-before-vibes" rel="noopener noreferrer"&gt;companion demo on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I compared two prompts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v1&lt;/strong&gt; is the original prompt from the last post. It asks for what is waiting on me and what I promised, but leaves much of the meaning implicit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v2&lt;/strong&gt; spells out the rules: a question or request to me that I haven't answered, a promise in any language including Hinglish, and no statements, rhetorical questions, questions addressed to other people or things already done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First, the vibes check. I ran both prompts on three easy cases, the kind you'd try by hand. Both got all three right in every run. If that's all you look at, v1 is fine.&lt;/p&gt;

&lt;p&gt;Then I ran both prompts on all 24 cases using &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt; through Groq. I repeated the run three times. Each sequence in the table is in run order: one, two, then three.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;v1, runs 1 / 2 / 3&lt;/th&gt;
&lt;th&gt;v2, runs 1 / 2 / 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Three easy cases passed&lt;/td&gt;
&lt;td&gt;3/3, 3/3, 3/3&lt;/td&gt;
&lt;td&gt;3/3, 3/3, 3/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cases passed, out of 24&lt;/td&gt;
&lt;td&gt;18, 17, 16&lt;/td&gt;
&lt;td&gt;22, 23, 23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False positives&lt;/td&gt;
&lt;td&gt;7, 6, 8&lt;/td&gt;
&lt;td&gt;1, 1, 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missed findings, out of 17&lt;/td&gt;
&lt;td&gt;0, 1, 1&lt;/td&gt;
&lt;td&gt;1, 0, 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A case only passes when the expected findings are present, nothing extra is returned, the finding has the right kind and its quote passes the evidence check. One bad finding can make the whole case fail.&lt;/p&gt;

&lt;p&gt;The difference is in the cases you wouldn't think to try. v1 said a client's "Can you send the invoice?" was still waiting on me, two messages after I'd sent it. It flagged "Why is Bengaluru traffic like this every single day?" as a question for me. And it marked Arjun's "Bro the API docs link is broken" as waiting on me, the same mistake I found by eye in my last post. This time a test case catches it every run.&lt;/p&gt;

&lt;p&gt;v2 isn't perfect either. The rhetorical question fooled it in every run, and in one run it missed a Hinglish promise that v1 got right. That's the point of a fixed set: you see exactly which cases moved, in which direction, instead of relying on a feeling that it's better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The judge agreed with the mistakes
&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%2Fcknlxoaly59507o091kv.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%2Fcknlxoaly59507o091kv.png" alt="Fig. 3: The judge against my labels. An AI judge gave every finding a pass or fail with a reason. Compared with labels written by hand, it never failed a correct finding, but it passed six of v1's wrong findings in every run, so its mistakes all pointed the same way." width="800" height="726"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also ran an AI judge over the findings the prompts returned. It saw each finding together with the chat it came from, and had to give a pass or fail: was this really something waiting on Sayam, or really a promise he made? I then compared those judgements with the labels I had written by hand.&lt;/p&gt;

&lt;p&gt;In every v1 run, the judge passed six findings that my labels said were wrong. It never failed one of the correct findings it judged. The examples included the broken link, the rhetorical question and the invoice I had already sent. Its explanations sounded reasonable: a broken link "requires Sayam to respond or fix"; a rhetorical question "legitimately expects a reply".&lt;/p&gt;

&lt;p&gt;There is an important limit here: the judge only sees findings that the prompt returned. It cannot catch a missed promise, because there is no finding for it to judge. Its agreement is not a second score for the prompt and it is not a measure of recall.&lt;/p&gt;

&lt;p&gt;This also reminded me of the limitation in my first production judge, which only saw the generated reply and not the messages behind it. The setups are different, but the lesson is similar: a plausible explanation is not evidence.&lt;/p&gt;

&lt;p&gt;This is why I wouldn't trust an LLM judge just because it returns a confident explanation. Anthropic recommends calibrating judges against human experts, and the &lt;a href="https://arxiv.org/abs/2306.05685" rel="noopener noreferrer"&gt;2023 LLM as a judge study&lt;/a&gt; documented position, verbosity and self enhancement biases. A judge can be useful at scale, but its disagreements need to be checked against labels for your own task.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd start evals for an AI feature tomorrow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with 20 to 50 cases from real failures.&lt;/strong&gt; That's Anthropic's recommendation for a useful first set. My public demo uses synthetic messages for privacy, so it demonstrates the method rather than measuring the assistant on production conversations. When building your own set, turn real failures into cases after removing or replacing private details.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the expected answer down by hand.&lt;/strong&gt; Your labels define what success means. If the right answer is unclear to you, the model cannot reliably infer it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer pass or fail for concrete checks.&lt;/strong&gt; Hamel Husain and Shreya Shankar argue that binary judgements make the criteria clearer and more consistent than a score out of ten. A seven out of ten doesn't tell you what to fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grade with code first.&lt;/strong&gt; Is the quote in the message? Is the sender right? Is the JSON valid? Code is cheap, fast and consistent for facts that can be checked deterministically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a judge only when code cannot judge the quality.&lt;/strong&gt; Give it a clear rubric and compare its decisions with your labels. Check the disagreements instead of trusting its score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a holdout set.&lt;/strong&gt; Use some cases while changing the prompt, but keep others out of that loop so you can see whether the improvement generalises.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the set after every prompt or model change.&lt;/strong&gt; Keep old failures in it, so a fix never quietly brings an old bug back.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You don't need an evaluation platform to start. My demo is a JSONL file, two graders and a loop. The TypeScript runner has no package dependencies; a real model run still needs a provider API key and has to respect that provider's limits.&lt;/p&gt;

&lt;p&gt;If you do want a tool, OpenAI's &lt;a href="https://developers.openai.com/cookbook/examples/evaluation/moving-from-openai-evals-to-promptfoo" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; points to Promptfoo. In March 2026 Promptfoo &lt;a href="https://www.promptfoo.dev/blog/promptfoo-joining-openai/" rel="noopener noreferrer"&gt;announced it had agreed to be acquired by OpenAI&lt;/a&gt; and said it would remain open source. &lt;a href="https://inspect.aisi.org.uk/" rel="noopener noreferrer"&gt;Inspect AI&lt;/a&gt; is another open source option, developed by the UK AI Security Institute and Meridian Labs.&lt;/p&gt;

&lt;p&gt;And this isn't just my concern. In LangChain's &lt;a href="https://www.langchain.com/state-of-agent-engineering" rel="noopener noreferrer"&gt;State of Agent Engineering survey&lt;/a&gt;, 52.4% of organisations surveyed reported running offline evaluations on test sets. It's a useful signal about the teams in that survey, not a measurement of every company building AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this demo doesn't prove
&lt;/h2&gt;

&lt;p&gt;These are 24 synthetic cases, labelled by me, run three times on one model. They show how two prompts behaved on this particular set. They don't prove that v2 is better on real conversations, on another model or on a different task. My labels are judgement calls too: you could argue that a broken link really is waiting on me, and the judge did.&lt;/p&gt;

&lt;p&gt;The judge results have a narrower meaning still. It only judges returned findings, so it cannot say anything about missed items. This is a small check of where the judge agrees with my labels, not a measure of recall or a production quality score.&lt;/p&gt;

&lt;p&gt;The point isn't that v2 is the best prompt. It's that with a fixed set I can see which cases it fixed, which one it still gets wrong and which one it broke once. Then I can argue about specific cases instead of a feeling that a prompt is better.&lt;/p&gt;

&lt;p&gt;Vibes are how you find what to test. Evals are how you know what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/docs/deprecations" rel="noopener noreferrer"&gt;OpenAI: deprecations, including the Evals platform&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/cookbook/examples/evaluation/moving-from-openai-evals-to-promptfoo" rel="noopener noreferrer"&gt;OpenAI: moving from OpenAI Evals to Promptfoo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.promptfoo.dev/blog/promptfoo-joining-openai/" rel="noopener noreferrer"&gt;Promptfoo: Promptfoo is joining OpenAI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;Anthropic: Demystifying evals for AI agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hamel.dev/blog/posts/evals-faq/" rel="noopener noreferrer"&gt;Hamel Husain and Shreya Shankar: AI evals FAQ&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://arxiv.org/abs/2306.05685" rel="noopener noreferrer"&gt;Zheng et al.: Judging LLM as a Judge with MT Bench and Chatbot Arena&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.langchain.com/state-of-agent-engineering" rel="noopener noreferrer"&gt;LangChain: State of Agent Engineering&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://inspect.aisi.org.uk/" rel="noopener noreferrer"&gt;Inspect AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sayamjn/shipping-with-ai/tree/main/02-evals-before-vibes" rel="noopener noreferrer"&gt;The demo: cases, prompts, graders and results&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>evals</category>
    </item>
  </channel>
</rss>
