<?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: Anvay Singh</title>
    <description>The latest articles on DEV Community by Anvay Singh (@anvaysingh).</description>
    <link>https://dev.to/anvaysingh</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%2F4014054%2F4df33505-c475-4a51-a1d1-d70a7495575e.png</url>
      <title>DEV Community: Anvay Singh</title>
      <link>https://dev.to/anvaysingh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anvaysingh"/>
    <language>en</language>
    <item>
      <title>I built a job-application copilot that can't lie about me</title>
      <dc:creator>Anvay Singh</dc:creator>
      <pubDate>Wed, 02 Sep 2026 07:22:12 +0000</pubDate>
      <link>https://dev.to/anvaysingh/i-built-a-job-application-copilot-that-cant-lie-about-me-1djp</link>
      <guid>https://dev.to/anvaysingh/i-built-a-job-application-copilot-that-cant-lie-about-me-1djp</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I built &lt;strong&gt;Zapply&lt;/strong&gt;, a personal job-application copilot. It finds jobs, ranks them against my resume, and drafts a tailored application. I paste and submit it myself.&lt;/li&gt;
&lt;li&gt;The design rule I kept everywhere: &lt;strong&gt;every check is closed by a program that judges the model's output.&lt;/strong&gt; The model proposes, a plain function decides.&lt;/li&gt;
&lt;li&gt;Live: &lt;a href="https://zapply-az41.onrender.com" rel="noopener noreferrer"&gt;zapply-az41.onrender.com&lt;/a&gt;. Code: &lt;a href="https://github.com/AnvaySingh/zapply" rel="noopener noreferrer"&gt;github.com/AnvaySingh/zapply&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;Applying to jobs is a lot of repetitive work. Read the posting, figure out if you fit, rewrite your bullets to match, answer the same screening questions again. The tools that automate the whole thing (including the submit) sit in a legal and ethical grey zone, and honestly the interesting part was never the clicking.&lt;/p&gt;

&lt;p&gt;So I set a boundary and built to it. Zapply does everything up to the submit button and hands me a finished packet. A human (me) always makes the final submit. The line is simple: automated &lt;strong&gt;reading&lt;/strong&gt; of job openings is fine, automated &lt;strong&gt;writing&lt;/strong&gt; (applying) is not. Copilot, never autopilot.&lt;/p&gt;

&lt;p&gt;That gave me a clean project to learn the full applied-AI stack by hand: structured extraction, embeddings, grounded generation, orchestration, evaluation, and deployment. This post walks through each piece and the one idea that ties them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule: gates are programs, not model opinions
&lt;/h2&gt;

&lt;p&gt;Here is the rule I applied at every step. When a step produces model output, I also write the program that judges it.&lt;/p&gt;

&lt;p&gt;"The answer is faithful to my resume" is decided by a checker function that reads the profile. "This application is ready" is decided by a status flag a human flips. LLMs are non-deterministic and fail in quiet, plausible ways, so a loop closed by another opinion is not really closed.&lt;/p&gt;

&lt;p&gt;Keep that in mind as you read. Most sections below are the same idea in a different place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;job boards / RSS  -&amp;gt;  ingest  -&amp;gt;  one clean JobPosting schema
resume  -&amp;gt;  extract  -&amp;gt;  Profile (structured)
job     -&amp;gt;  extract  -&amp;gt;  Requirements (structured)
                     -&amp;gt;  match   (embeddings) -&amp;gt; score + why
                     -&amp;gt;  draft   (grounded)   -&amp;gt; bullets + answers
                     -&amp;gt;  faithfulness gate     (a program)
                     -&amp;gt;  human review          (I approve)
                     -&amp;gt;  packet                (paste-ready)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the concepts, one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A provider-agnostic seam for the LLM
&lt;/h2&gt;

&lt;p&gt;I did not want to hardcode a vendor. Every model call in Zapply goes through one thin client, never through a vendor SDK directly.&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;# one door for the whole app
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LLMClient&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;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt;        &lt;span class="c1"&gt;# free text out
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;complete_structured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;model_cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt; &lt;span class="c1"&gt;# a validated object out
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind that client is a small adapter per vendor. One speaks the Anthropic API, one speaks the OpenAI chat protocol (which covers Gemini, Groq, Ollama, and OpenAI itself). A single environment variable picks the provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LLM_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gemini   &lt;span class="c"&gt;# or anthropic, or openai&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payoff showed up on day one. I planned to use Claude, then the very first practical question was "do I have to pay to run this?". Because every call already went through one client, switching the whole app to Google Gemini's free tier took one environment variable. No business logic changed. That is the whole reason for the seam.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Tracing from line one
&lt;/h2&gt;

&lt;p&gt;LLM apps fail semantically. You do not get a stack trace, you get a subtly wrong answer. If you cannot replay a call, you cannot debug it.&lt;/p&gt;

&lt;p&gt;So before any business logic, I wired &lt;a href="https://langfuse.com" rel="noopener noreferrer"&gt;Langfuse&lt;/a&gt; tracing into the client. Every call becomes a "generation" with its prompt, output, model, and token usage attached. Because the tracing lives inside the one client, you cannot make a call that is not traced.&lt;/p&gt;

&lt;p&gt;One small thing that bit me: short-lived commands (a CLI run) have to flush traces before exiting, because the SDK batches them in the background. Exit too fast and the trace never leaves your machine. A &lt;code&gt;finally&lt;/code&gt; block fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Structured extraction (the 80% of real LLM work)
&lt;/h2&gt;

&lt;p&gt;Most "AI" work is not chatting. It is turning messy text into validated, typed data you can compute on. In Zapply that is resume to &lt;code&gt;Profile&lt;/code&gt; and job description to &lt;code&gt;Requirements&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The reliable technique is tool/function calling. You hand the model your schema as a tool it is forced to call, so its arguments &lt;em&gt;are&lt;/em&gt; your object. Then you validate with Pydantic. Invalid output is rejected right there, it never silently passes.&lt;/p&gt;

&lt;p&gt;I built this by hand first, then again with the &lt;a href="https://python.useinstructor.com/" rel="noopener noreferrer"&gt;&lt;code&gt;instructor&lt;/code&gt;&lt;/a&gt; library, to feel the difference. Library means less code and built-in retries. Hand-rolled means I own the prompt, the retry loop, and the tracing, and I understand exactly what breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke.&lt;/strong&gt; The short test resumes extracted fine with forced tool-calling. Then the full pipeline hit a real, long job description and Gemini's OpenAI-compatible endpoint started ignoring the forced tool call and returning prose instead. No structured output at all. The fix stayed inside my own provider: try tool-calling first, and fall back to JSON mode (ask for a JSON object, inject the schema into the prompt, parse leniently) when no tool call comes back.&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;tool_calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool_calls&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# happy path
# some endpoints (Gemini) drop the forced tool call on long inputs:
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_json_mode&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;      &lt;span class="c1"&gt;# fall back to JSON mode
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole reason for owning the seam instead of trusting one vendor's happy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Matching with embeddings, and where cosine lies
&lt;/h2&gt;

&lt;p&gt;How close is my resume to a role? Keyword overlap is brittle ("K8s" vs "Kubernetes"). Embeddings map text to a vector where similar meaning lands nearby, so cosine similarity captures meaning that keywords miss.&lt;/p&gt;

&lt;p&gt;I kept the naive version on purpose: a small local model (&lt;code&gt;all-MiniLM-L6-v2&lt;/code&gt;, 384 numbers per text) and in-memory cosine, no vector database.&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;cosine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1.0 = same direction, 0 = unrelated
&lt;/span&gt;    &lt;span class="n"&gt;denom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;linalg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;linalg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;denom&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;denom&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To evaluate a ranker you do not grade absolute numbers, you ask "is the ordering right?". I hand-ranked 15 roles for one profile and measured the &lt;strong&gt;Spearman rank correlation&lt;/strong&gt; between my order and the system's. It came out at 0.92, which is a strong agreement.&lt;/p&gt;

&lt;p&gt;Cosine has a real failure mode. It measures vocabulary proximity rather than fit. In my eval the worst-fit role (an enterprise sales job) out-scored a technical Product Manager role, because the PM's language sits further from engineering text than generic corporate sales does. The number felt wrong because it was. Cosine answered "how similar is the wording" when the question I cared about was "how good a fit is this person". A skill-overlap signal or a re-ranker is the fix, and I compute the skill overlap already for the card display.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Grounded drafting and a faithfulness gate
&lt;/h2&gt;

&lt;p&gt;This is the part I care most about. A model told to "make me sound great for this role" will happily invent a skill the job wants. On a job application, that is lying on your behalf.&lt;/p&gt;

&lt;p&gt;Grounding is not a polite request in the prompt. It is how you build the context. I hand the model a closed fact sheet (the only employers, skills, and credentials it may use) and frame the task as "reframe these real facts", with a hard rule: never claim a skill the candidate lacks, even if the role asks for it.&lt;/p&gt;

&lt;p&gt;The check comes next. The draft is structured, so each bullet reports the employer and the profile skills it used. A plain function verifies those against the profile, and independently scans the text for any role-required skill the candidate does not have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke, on purpose.&lt;/strong&gt; I pointed it at a role that demanded Rust and Kafka, which my test profile did not have. The drafter produced good bullets from real experience and did not claim Rust or Kafka. The faithfulness function confirmed it in the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Faithfulness gate passed — every claim traces to your resume.
Role skills you didn't claim: Rust, C/C++.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the anti-hallucination guarantee working. The model resisted the bait, and a program (not the model) confirmed it.&lt;/p&gt;

&lt;p&gt;I also run an &lt;strong&gt;LLM-as-judge&lt;/strong&gt;: a second model call that reads the fact sheet and labels each claim supported or unsupported. It is genuinely useful for subtle drift like an invented metric. But it is only a signal. The deterministic checker passes or fails the step. Using a model to grade a model is exactly the loop I refuse to close with an opinion.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Orchestration and a human gate that is a program
&lt;/h2&gt;

&lt;p&gt;The pieces above become one pipeline: ingest, extract, match, draft, review, packet. The interesting engineering is not the wiring, it is making the human review step impossible to skip.&lt;/p&gt;

&lt;p&gt;I modelled it with an &lt;code&gt;Application&lt;/code&gt; that carries a status. It starts &lt;code&gt;pending_review&lt;/code&gt;. A draft that fails the faithfulness check is marked &lt;code&gt;blocked&lt;/code&gt;, and the approve function raises if you try to approve a blocked one. The packet builder raises unless the status is &lt;code&gt;approved&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_faithful&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# can't wave through a draft the checker caught
&lt;/span&gt;        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ReviewError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed the faithfulness gate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ApplicationStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approved&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_packet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;ApplicationStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# no path from pending/blocked to a packet
&lt;/span&gt;        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;NotApprovedError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a packet requires human approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compose those two and the maker-checker rule holds by construction: a packet requires human approval, which requires machine faithfulness. Two exceptions enforce that at runtime, so the human step cannot be skipped by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Designing around cost
&lt;/h2&gt;

&lt;p&gt;The dev model is Gemini's free tier, which allows 20 requests a day. A single ingest run surfaces around 4,000 postings. If you extract requirements for every one, you hit the wall on posting number 21.&lt;/p&gt;

&lt;p&gt;So the pipeline is two-tier. A cheap local embedding pass ranks all postings with no LLM call, and only the top few survivors reach the expensive LLM steps (extract and draft). Cheap and wide first, expensive and narrow second. Matching the shape of the work to the shape of the budget is the difference between a demo that runs and one that fails immediately.&lt;/p&gt;

&lt;p&gt;The web app takes the same care. Browsing and matching are 100% local. The only calls that touch the LLM are the optional "AI analyze" and "generate packet" features, and on the public deployment those sit behind an access code so a stranger cannot spend my quota.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Making it cheap to host (ONNX)
&lt;/h2&gt;

&lt;p&gt;I wanted the demo online for free. Every free host kept failing for the same reason: PyTorch needs around 2 GB of RAM, and free tiers give you 512 MB.&lt;/p&gt;

&lt;p&gt;The fix was to stop hunting for a big free box and remove PyTorch. I swapped &lt;code&gt;sentence-transformers&lt;/code&gt; for &lt;a href="https://github.com/qdrant/fastembed" rel="noopener noreferrer"&gt;&lt;code&gt;fastembed&lt;/code&gt;&lt;/a&gt;, which runs the &lt;em&gt;same&lt;/em&gt; MiniLM model through ONNX Runtime instead of PyTorch. Same 384-dimension vectors, so the rankings do not change (I re-ran the Spearman eval and it was still 0.92). Memory dropped from about 2 GB to about 400 MB, and the app now fits a free tier. As a bonus the test suite got roughly ten times faster because it no longer imports torch.&lt;/p&gt;

&lt;p&gt;It is deployed on a free Render instance from a &lt;code&gt;Dockerfile&lt;/code&gt; that bakes the job snapshot and the model into the image at build time, so the container boots fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone starting this
&lt;/h2&gt;

&lt;p&gt;The thread through all of it is one sentence: &lt;strong&gt;do not let a model be the judge of its own work.&lt;/strong&gt; Extraction is validated by a schema. Matching is graded by a rank correlation against my labels. Faithfulness is decided by a function that scans for skills I do not have. Review is a status a human flips. The model is the powerful, unreliable part in the middle, and every loop around it is closed by a plain program.&lt;/p&gt;

&lt;p&gt;The other half is the boundary. Zapply stops one click short of submitting, on purpose. That single decision removed almost all of the risk and kept all of the learning, because the interesting engineering was always in the brain, not the hands.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://zapply-az41.onrender.com" rel="noopener noreferrer"&gt;zapply-az41.onrender.com&lt;/a&gt; (browse and match are open, the AI features are behind an access code). Code and a per-phase writeup: &lt;a href="https://github.com/AnvaySingh/zapply" rel="noopener noreferrer"&gt;github.com/AnvaySingh/zapply&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Hope you find it useful. Any kind of feedback and suggestions are highly appreciated. If you have built something similar, I would like to hear how you handled the faithfulness problem. &lt;code&gt;#discuss&lt;/code&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>machinelearning</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>1.Setting up my first Linux server on a Mac, and the scary messages that are actually fine</title>
      <dc:creator>Anvay Singh</dc:creator>
      <pubDate>Thu, 13 Aug 2026 18:02:23 +0000</pubDate>
      <link>https://dev.to/anvaysingh/1setting-up-my-first-linux-server-on-a-mac-and-the-scary-messages-that-are-actually-fine-3lph</link>
      <guid>https://dev.to/anvaysingh/1setting-up-my-first-linux-server-on-a-mac-and-the-scary-messages-that-are-actually-fine-3lph</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I set up a real Ubuntu Server VM on an Apple Silicon Mac using UTM, as step one toward platform engineering.&lt;/li&gt;
&lt;li&gt;The setup is short. The confusing part is a few messages that look like errors but are normal.&lt;/li&gt;
&lt;li&gt;This post explains what each one means, so you don't get stuck where I almost did.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm a senior software engineer, and I'm moving toward platform and cloud engineering. Almost everything I want to learn next, like containers, Kubernetes, and cloud servers, runs on Linux. So the first step was to get a Linux machine I can log into, break, and rebuild.&lt;/p&gt;

&lt;p&gt;I didn't want to use the Mac terminal for this. It looks similar to Linux, but it is not Linux underneath. Production servers run Linux, and I wanted to learn the real thing. So I set up a virtual machine instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a virtual machine is
&lt;/h2&gt;

&lt;p&gt;In simple language, a virtual machine (VM) is a computer running inside your computer. A piece of software pretends to be real hardware, and a second operating system runs inside that pretend hardware, kept separate from your actual system.&lt;/p&gt;

&lt;p&gt;Your real machine is the &lt;strong&gt;host&lt;/strong&gt; (macOS in my case). The system running inside is the &lt;strong&gt;guest&lt;/strong&gt; (Ubuntu). If I mess up the guest, I can delete it and start again, and my Mac is untouched. That freedom to break things is the whole reason to use a VM.&lt;/p&gt;

&lt;p&gt;Here is what I used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Host:&lt;/strong&gt; Apple Silicon Mac&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Virtualization app:&lt;/strong&gt; UTM (free)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guest OS:&lt;/strong&gt; Ubuntu Server 24.04 LTS, ARM64&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setting it up, step by step
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download &lt;strong&gt;UTM&lt;/strong&gt; from getutm.app and drag it into Applications.&lt;/li&gt;
&lt;li&gt;Download &lt;strong&gt;Ubuntu Server 24.04 LTS&lt;/strong&gt; from ubuntu.com. Pick the &lt;strong&gt;ARM64 / AArch64&lt;/strong&gt; version, not the x86 one. More on why below.&lt;/li&gt;
&lt;li&gt;In UTM, click &lt;strong&gt;+&lt;/strong&gt;, then &lt;strong&gt;Virtualize&lt;/strong&gt;, then &lt;strong&gt;Linux&lt;/strong&gt;. Under "Boot ISO Image", browse to the Ubuntu file you downloaded.&lt;/li&gt;
&lt;li&gt;Give the VM about &lt;strong&gt;4 GB of RAM&lt;/strong&gt; and a &lt;strong&gt;32 GB disk&lt;/strong&gt;, name it, and press play to boot it.&lt;/li&gt;
&lt;li&gt;Follow the text installer. Use the arrow keys, since the mouse does not work here.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why ARM64 and not x86
&lt;/h2&gt;

&lt;p&gt;This one matters, and it is easy to get wrong.&lt;/p&gt;

&lt;p&gt;A CPU only understands one set of instructions, called its architecture. Apple Silicon chips use &lt;strong&gt;ARM&lt;/strong&gt;. Most Intel and AMD chips use &lt;strong&gt;x86-64&lt;/strong&gt;. An operating system is built ahead of time for one of them, so you have to match the download to your chip.&lt;/p&gt;

&lt;p&gt;On an Apple Silicon Mac, that means the ARM64 build of Ubuntu. Pick the x86 one and it will either refuse to run or crawl along under slow emulation. You will meet this same ARM vs x86 choice later with Docker images, so it is a good habit to start now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first message that worried me
&lt;/h2&gt;

&lt;p&gt;When the install finished, the screen said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Please remove the installation medium, then press ENTER
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks like something went wrong. It didn't. It means the install finished, and Ubuntu now wants you to remove the virtual install disc so it can start from the hard drive instead.&lt;/p&gt;

&lt;p&gt;To understand why, you need one idea: a computer starts up by looking through its devices in order and booting the first one it can. The install file (called an &lt;strong&gt;ISO&lt;/strong&gt;, a single file that acts like an install CD) is still attached, so the VM keeps booting back into the installer.&lt;/p&gt;

&lt;p&gt;Here is how to fix it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Shut the VM down.&lt;/li&gt;
&lt;li&gt;Open the VM's settings in UTM.&lt;/li&gt;
&lt;li&gt;Find the &lt;strong&gt;CD/DVD&lt;/strong&gt; drive pointing at the Ubuntu ISO and click &lt;strong&gt;Clear&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Save, then start the VM again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now it boots straight to a login prompt, because the only thing left to boot from is the disk where Ubuntu lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second message that worried me
&lt;/h2&gt;

&lt;p&gt;At the login, I typed my password and nothing showed up. No dots, no stars, no moving cursor. It looked frozen.&lt;/p&gt;

&lt;p&gt;It wasn't. Linux hides the password as you type, on purpose. Showing stars would leak how long your password is, and showing nothing leaks nothing at all. This is normal. Type your password without looking for feedback and press Enter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first real command
&lt;/h2&gt;

&lt;p&gt;Once I was in, I ran my first proper command. It updates the whole system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update    &lt;span class="c"&gt;# refresh the list of available packages and their versions (installs nothing)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="c"&gt;# install newer versions of what you already have; -y answers "yes" for you&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;apt&lt;/code&gt; is Ubuntu's &lt;strong&gt;package manager&lt;/strong&gt;, the tool that installs and updates software from trusted sources so you are not downloading installers from random websites. The two commands do different jobs, and the order is on purpose. First you refresh what is available, then you act on it.&lt;/p&gt;

&lt;p&gt;One line in the output said &lt;code&gt;Not upgrading yet due to phasing&lt;/code&gt;. That is also normal. Ubuntu releases some updates to a small share of machines first, so problems get caught before everyone gets them. My machine just wasn't in that batch yet.&lt;/p&gt;

&lt;p&gt;When you want to stop for the day, shut the machine down cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;poweroff  &lt;span class="c"&gt;# shuts Linux down properly instead of pulling the plug&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrap up
&lt;/h2&gt;

&lt;p&gt;Almost everything that worried me on day one was normal behaviour that I did not have the background for yet. Once I understood boot order, hidden passwords, and how &lt;code&gt;apt&lt;/code&gt; works, the scary messages turned into ordinary ones. That is the real skill I am building here, learning the ideas behind the steps so the next problem is less confusing than the last.&lt;/p&gt;

&lt;p&gt;This is part one of a series where I go from senior developer to platform engineer in public. The notes and any scripts for this series will live here: [your repo link].&lt;/p&gt;

&lt;p&gt;Hope you find it useful. All sorts of comments and feedback are welcome.&lt;/p&gt;

&lt;p&gt;Did anything trip you up when you set up your first VM? Tell me in the comments so the next person can avoid it.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>beginners</category>
      <category>virtualization</category>
    </item>
    <item>
      <title>Part 2: When Nobody Grades Their Own Homework</title>
      <dc:creator>Anvay Singh</dc:creator>
      <pubDate>Mon, 13 Jul 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/anvaysingh/part-2-when-nobody-grades-their-own-homework-5b02</link>
      <guid>https://dev.to/anvaysingh/part-2-when-nobody-grades-their-own-homework-5b02</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some things can't be checked with a number, like whether an animation feels right.&lt;/li&gt;
&lt;li&gt;So a second, read-only agent grades the first one against a written rubric it is not allowed to edit.&lt;/li&gt;
&lt;li&gt;In my run the reviewer rejected the builder three times, and the most interesting problem it caught was in the test evidence, not the code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In &lt;a href="//./devto-part-1-goal-loop.md"&gt;Part 1&lt;/a&gt; I built a loop that chased a number, frames per second. But most of what we care about in software is not a number. "Does this region switch feel good?" has no assert. You cannot write &lt;code&gt;expect(feelsRight).toBe(true)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So this part is about how you check quality when there is nothing to measure. The approach I used is a second agent that grades the first one against a written rubric. In my run the reviewer turned the builder down three times before it approved anything, and the most interesting problem it found was not in the code at all.&lt;/p&gt;

&lt;p&gt;A quick reminder of the definition, since this is Part 2 of 3: a loop is an external script that runs the agent, a separate check the agent cannot edit decides pass or fail, and it repeats until it passes or hits a limit. In Part 1 the check was a Playwright test. Here the check is another agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem this loop solves
&lt;/h2&gt;

&lt;p&gt;In the browser you can switch regions, say from Tamil to Korean, which swaps out hundreds of posters at once. Done badly, the grid flashes blank and jumps around. Done well, it fades from one set to the next, keeps its layout, shows a loading state, and puts you back at the top.&lt;/p&gt;

&lt;p&gt;"Done well" is subjective, which is the kind of thing you cannot unit-test. So I wrote it down as a rubric and had a second agent apply it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bar: a rubric a person owns
&lt;/h2&gt;

&lt;p&gt;The rubric is seven plain-English checks in a file, and the first line is the one that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Overall APPROVED requires every item PASS. This file is human-owned.
Only a person changes the bar.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The seven items are things like a crossfade instead of a flash, no layout shift, a visible loading state, posters that stay 2:3, and landing back at the top after a switch. Nothing fancy, just a written version of "good" that a reviewer can apply the same way every time.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two agents that never touch
&lt;/h2&gt;

&lt;p&gt;The important decision is that the builder and the reviewer are two separate Claude Code agents with different permissions.&lt;/p&gt;

&lt;p&gt;The builder can write code:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Edit, Write, Grep, Glob&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The reviewer is read-only. It cannot edit anything, including the rubric:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Grep, Glob&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Each one runs in its own context. This answers an obvious question: why not let one agent do both jobs? Because an agent that writes code and then reviews the same code tends to approve it. Mine did, almost word for word, as you will see. The reviewer never sees the builder's reasoning or its summary. It only sees the code and the evidence, which is a set of before, during, and after screenshots from an automated capture.&lt;/p&gt;

&lt;p&gt;The loop 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;builder makes one change -&amp;gt; capture screenshots -&amp;gt; reviewer scores the rubric
        ^                                                     |
        |______ CHANGES-REQUESTED: feed feedback back ________|
                  (until VERDICT: APPROVED, or cap = 6)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The builder cannot edit the rubric, the tests, or the capture script, which is the same protected-files rule from Part 1.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;The reviewer turned the builder down three times. Here is how it went.&lt;/p&gt;

&lt;p&gt;Round 1. The builder replaced the whole grid with a loading skeleton during the swap and summed up its work as "eliminating the hard cut and empty-grid flash." The reviewer looked at the screenshots and disagreed, because replacing every poster with identical skeleton cards is itself a hard cut rather than a crossfade. Verdict: CHANGES-REQUESTED. The builder had claimed it fixed the flash, and the evidence said it had not. If the same agent had been reviewing its own work, that version would have shipped.&lt;/p&gt;

&lt;p&gt;Round 2. The builder reworked it into a real crossfade, with the old posters fading out over the new grid. The reviewer agreed the fade was there now, and then caught the next issue: the required loading state never actually appeared, because the local posters decode instantly. Correct, and easy to miss. Still not approved.&lt;/p&gt;

&lt;p&gt;Round 3. The builder added the loading state, and six of the seven items clearly passed. The seventh got a verdict I did not expect, though. Not a fail, but BLOCKED:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The after frame sits at the top, but the before frame was already at the top, so a scroll reset from the middle of the list was never actually tested.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The most interesting bug was not in the code
&lt;/h2&gt;

&lt;p&gt;That third verdict is my favourite part of the project. The feature was fine. The code did reset the scroll to the top. What failed was the evidence: the screenshot always started at the top, so there was no way to see the reset happen. Before and after looked the same, and the reviewer refused to give credit for something it could not see.&lt;/p&gt;

&lt;p&gt;The fix was in the capture script, not the app. It now scrolls partway down before switching, so the reset is visible:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// ...switch region...&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;afterScroll&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// proves it snapped back to the top&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A reviewer is only as good as the evidence you give it. A good reviewer pointed at a blind spot still cannot see anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a gate does not cover
&lt;/h2&gt;

&lt;p&gt;The run stopped at round 4, not because it failed but because I hit a Claude usage limit. These local loops run on a Claude Code subscription rather than a paid API key, which I will come back to in Part 3. So I checked the last rubric items by hand and merged.&lt;/p&gt;

&lt;p&gt;One honest detail: the merged code had a couple of lint errors, because the loop only ran the UX rubric, not lint. I cleaned those up myself. A loop checks what its gate checks and nothing else, so it helps to know what your gate leaves out.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I took from Part 2
&lt;/h2&gt;

&lt;p&gt;The reviewer has to be independent: its own context, read-only, and no ability to edit the code or the rubric. An agent reviewing its own work is close to no review at all. The builder's confidence did not catch the flash. The screenshots did.&lt;/p&gt;

&lt;p&gt;In Part 3 the loop changes again. The first two loops you start and watch. The last one runs on a schedule while you are asleep, and its whole job is to do nothing most nights. I will also cover the bug that broke all three loops and the setup that lets this one run for free.&lt;/p&gt;

&lt;p&gt;The full project, with the builder and reviewer agents, is on GitHub:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/AnvaySingh" rel="noopener noreferrer"&gt;
        AnvaySingh
      &lt;/a&gt; / &lt;a href="https://github.com/AnvaySingh/moviebrowse" rel="noopener noreferrer"&gt;
        moviebrowse
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;MovieBrowse — a loop-engineering learning project&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A small, deliberately-boring &lt;strong&gt;regional cinema browser&lt;/strong&gt;: a fast, virtualized grid of
film posters for one "region" (a language / film industry, not a country), with a
manual region selector and a geo-IP default.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;But the website is not the point.&lt;/strong&gt; It's a &lt;em&gt;vehicle&lt;/em&gt; for learning and demonstrating
&lt;strong&gt;loop engineering with Claude Code&lt;/strong&gt; — building automated loops where an AI agent makes
a change, a deterministic check decides whether that change is good, and the loop repeats
until the check passes. The interesting part is the loops, not the movie site. If the
website ever became the hard part, something went wrong.&lt;/p&gt;
&lt;p&gt;This repo ships three loops, each a different &lt;em&gt;shape&lt;/em&gt;, each anchored in a real config and
a real failure. There's one write-up per loop in &lt;a href="https://github.com/AnvaySingh/moviebrowse/./blog" rel="noopener noreferrer"&gt;&lt;code&gt;blog/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What is a "loop"?&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;A &lt;strong&gt;loop&lt;/strong&gt; here means:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;run something → check a verifiable&lt;/strong&gt;…&lt;/p&gt;
&lt;/blockquote&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/AnvaySingh/moviebrowse" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Feedback and different takes are welcome. Would you trust an AI to review another AI's work, or does a human still need to sign off?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>claude</category>
      <category>automation</category>
    </item>
    <item>
      <title>Part 1 : I Gave an AI Agent an Impossible Target to See If It Would Cheat</title>
      <dc:creator>Anvay Singh</dc:creator>
      <pubDate>Mon, 13 Jul 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/anvaysingh/i-gave-an-ai-agent-an-impossible-target-to-see-if-it-would-cheat-4ooe</link>
      <guid>https://dev.to/anvaysingh/i-gave-an-ai-agent-an-impossible-target-to-see-if-it-would-cheat-4ooe</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A "loop" is not an agent grading its own work. It is an external script that re-runs the agent, plus a separate check the agent cannot edit.&lt;/li&gt;
&lt;li&gt;I turned "feels smooth" into an FPS number and let the loop optimize toward it.&lt;/li&gt;
&lt;li&gt;I set the target too high to be reachable on a 60Hz screen. The loop kept failing but never faked the result. The bug was in my number, not the code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Could I get an AI agent to make my website faster without me sitting there, running it, reading the numbers, and running it again? That is what this series is about. Not how I built a website, because the website is boring on purpose, but how you wrap an agent in a loop that works toward a goal on its own, and how you stop it from cheating along the way.&lt;/p&gt;

&lt;p&gt;In this first part I want to explain what a loop actually is, because there is a common misconception, and then walk through a real one. I set this loop a target that was physically impossible to reach and watched what it did. That run taught me more than a passing test would have.&lt;/p&gt;

&lt;p&gt;This is Part 1 of 3. All three parts use the same small movie-poster website as the example, but the website is never the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a loop is, and what it is not
&lt;/h2&gt;

&lt;p&gt;I had a wrong idea about this at first, so let me clear it up. A loop is not an agent prompting itself, grading its own work, and deciding when it is done. An agent left to mark its own homework will usually tell you it passed.&lt;/p&gt;

&lt;p&gt;A loop is closer to this: an external script runs the agent, a separate check that the agent cannot edit decides whether the result is good, and that repeats until the check passes or you hit a limit.&lt;/p&gt;

&lt;p&gt;There are three parts to it that come up again and again:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The driver: the script that re-runs the agent. This is the thing that removes the manual work, not the agent.&lt;/li&gt;
&lt;li&gt;The gate: the check that decides pass or fail. The agent makes changes, but it never decides when to stop.&lt;/li&gt;
&lt;li&gt;The cap: a limit, so a stuck loop gives up instead of running forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One rule matters more than the rest. The thing being checked must never be able to edit the check, and only a person is allowed to change the target. If the agent can edit its own test or lower its own bar, the loop is pointless. So most of the work in a loop is not writing a clever prompt. It is building a check the agent cannot get around. That idea runs through the whole series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem this loop solves
&lt;/h2&gt;

&lt;p&gt;The website is a grid of movie posters you can scroll and zoom through. It holds hundreds of real images and only keeps the visible ones in the page, so it stays light. The one thing it has to do well is feel smooth.&lt;/p&gt;

&lt;p&gt;"Feels smooth" is a vibe, and you cannot put a vibe in a loop, so the first job is to turn it into a number. I used two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sustained frames per second (FPS) while scrolling and zooming, and&lt;/li&gt;
&lt;li&gt;no single main-thread task longer than 50 milliseconds, since a long task is what makes scrolling stutter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One quick note on why the data matters. I could have filled the grid with plain coloured boxes as placeholders, and it would have been less work. But coloured boxes are almost free to draw, while real images cost real decode time, memory, and paint. A performance test running against coloured boxes would be measuring something that isn't really there. So I used about 200 real JPEGs, with the same &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; markup I would use with a live movie API. A check is only as honest as the thing it measures.&lt;/p&gt;

&lt;p&gt;A Playwright test drives the grid, records frame times, writes them to a file, and asserts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FPS_TARGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;65&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LONG_TASK_MAX_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avgFps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FPS_TARGET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longestTaskMs&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LONG_TASK_MAX_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That test file is the definition of "smooth" for this project. It is the gate, and everything else works toward it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The loop itself
&lt;/h2&gt;

&lt;p&gt;The driver is a small bash script. This is its core:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAX_ITERS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;          &lt;span class="c"&gt;# cap = 12&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;npm run gate&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PASS — gate green. Loop 1 complete."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
  &lt;span class="k"&gt;fi&lt;/span&gt;
  &lt;span class="c"&gt;# gate failed, so ask Claude for one optimization&lt;/span&gt;
  claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; acceptEdits
  &lt;span class="c"&gt;# ...safety checks... then commit and loop again&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The prompt tells Claude to read the failing report, make one targeted optimization, and stop, and not to touch the test or the threshold. A prompt is only words though, so the script also enforces it. Before it commits each round, it checks whether any protected file changed, and if one did, it reverts everything and stops.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;protected_changed&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ABORT — protected gate files were modified. Reverting."&lt;/span&gt;
  git checkout &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROTECTED&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;So if the agent tried to pass by lowering the target, the loop would undo it before the change ever counted.&lt;/p&gt;
&lt;h2&gt;
  
  
  The failure
&lt;/h2&gt;

&lt;p&gt;I set &lt;code&gt;FPS_TARGET = 65&lt;/code&gt; and ran it. Here is iteration 1:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;FAIL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"avgFps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;60.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longestTaskMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longTasksOver50"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The grid was already doing about 60 FPS with zero long tasks. It only "failed" because 60.1 is below 65.&lt;/p&gt;

&lt;p&gt;Claude made a real optimization anyway. It moved a shimmer animation off the main thread and onto the GPU, which is a good change. Iteration 2:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;FAIL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"avgFps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;60.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longestTaskMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;60.1 became 60.2, and it was never going to climb much higher. The reason is the display, not the code. The browser measures frames against the screen's refresh rate, which is around 60Hz, so measured FPS cannot go past the monitor's ceiling no matter how good the code gets.&lt;/p&gt;

&lt;p&gt;The target was the problem, not the code. I had asked for a number the hardware could not produce. The loop could not reach it, and instead of faking the result, it just kept failing, because the protected-files check stopped it from touching the test.&lt;/p&gt;

&lt;p&gt;Fixing it was my job, not the agent's, since only a person changes the target. I lowered it to something reachable:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FPS_TARGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;58&lt;/span&gt;   &lt;span class="c1"&gt;// stay near 60, don't drop frames&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I ran it again and it passed on the first gate.&lt;/p&gt;

&lt;p&gt;There is a subtler point here too. On a 60Hz display there is no FPS target that is both currently failing and actually reachable, because you are already sitting at the ceiling. If you wanted a loop that improves its way to green over several rounds, FPS is the wrong thing to measure. You would measure something like p95 frame time or the number of dropped frames instead. Picking the right number to chase is part of the work.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I took from Part 1
&lt;/h2&gt;

&lt;p&gt;A goal loop is only as good as the number you give it. The run against 65 looked like a failure, but it was useful, because it showed the loop would rather keep failing than fake a pass. That is what you want to see before you trust it with anything.&lt;/p&gt;

&lt;p&gt;In Part 2 the check changes. Loop 1 measured a number, but a lot of what we care about is not a number. "Does this animation feel right?" has no assert. So I hand the judgment to a second, separate agent, and it turns the first one down three times before it agrees to a pass.&lt;/p&gt;

&lt;p&gt;The full project, with the loop drivers, gates, and agents, is on GitHub:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/AnvaySingh" rel="noopener noreferrer"&gt;
        AnvaySingh
      &lt;/a&gt; / &lt;a href="https://github.com/AnvaySingh/moviebrowse" rel="noopener noreferrer"&gt;
        moviebrowse
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;MovieBrowse — a loop-engineering learning project&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A small, deliberately-boring &lt;strong&gt;regional cinema browser&lt;/strong&gt;: a fast, virtualized grid of
film posters for one "region" (a language / film industry, not a country), with a
manual region selector and a geo-IP default.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;But the website is not the point.&lt;/strong&gt; It's a &lt;em&gt;vehicle&lt;/em&gt; for learning and demonstrating
&lt;strong&gt;loop engineering with Claude Code&lt;/strong&gt; — building automated loops where an AI agent makes
a change, a deterministic check decides whether that change is good, and the loop repeats
until the check passes. The interesting part is the loops, not the movie site. If the
website ever became the hard part, something went wrong.&lt;/p&gt;
&lt;p&gt;This repo ships three loops, each a different &lt;em&gt;shape&lt;/em&gt;, each anchored in a real config and
a real failure. There's one write-up per loop in &lt;a href="https://github.com/AnvaySingh/moviebrowse/./blog" rel="noopener noreferrer"&gt;&lt;code&gt;blog/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What is a "loop"?&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;A &lt;strong&gt;loop&lt;/strong&gt; here means:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;run something → check a verifiable&lt;/strong&gt;…&lt;/p&gt;
&lt;/blockquote&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/AnvaySingh/moviebrowse" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;If you would do any of this differently, I would like to hear it. What is the one thing you would have your own loop check?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>beginners</category>
      <category>claude</category>
    </item>
    <item>
      <title>Part 3: A Loop Whose Job Is to Do Nothing</title>
      <dc:creator>Anvay Singh</dc:creator>
      <pubDate>Mon, 13 Jul 2026 18:30:00 +0000</pubDate>
      <link>https://dev.to/anvaysingh/part-3-a-loop-whose-job-is-to-do-nothing-2kog</link>
      <guid>https://dev.to/anvaysingh/part-3-a-loop-whose-job-is-to-do-nothing-2kog</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This loop runs on a schedule and succeeds by doing nothing almost every night.&lt;/li&gt;
&lt;li&gt;The pass/fail check is plain deterministic code, with no AI in the decision.&lt;/li&gt;
&lt;li&gt;It can run entirely free on your own machine. Only the cloud/CI version needs a paid API key. Plus the one bug that broke all three loops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first two loops in this series work the same way from your side: you start them and watch. This last one runs on a schedule, like a nightly job, while you are not looking. That changes what success even means.&lt;/p&gt;

&lt;p&gt;A scheduled maintenance loop is doing its job when it does nothing. It should run every night, find nothing wrong, cost almost nothing, and still be there on the night something actually breaks. This part covers that loop, the hook mechanism that the whole series relies on, and a bug that broke all three loops in the least convenient place possible.&lt;/p&gt;

&lt;p&gt;The definition one more time, since this is Part 3 of 3: a loop is a trigger that runs the agent, a check the agent cannot edit that decides pass or fail, and a repeat, or here a wait until the next run. The only new thing this time is the trigger. A timer starts it instead of you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem this loop solves
&lt;/h2&gt;

&lt;p&gt;The browser's poster data is baked ahead of time into JSON files and images. In a real deployment that data goes stale as films are added and metadata changes, so you want to regenerate it every so often and confirm it is still valid before it ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on a timer -&amp;gt; regenerate the data -&amp;gt; validate it -&amp;gt; green ships, red shouts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  The gate: a plain check with no model in it
&lt;/h2&gt;

&lt;p&gt;The check is a Node script. For every region file it confirms three things and exits non-zero if any of them fail:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;it matches the expected JSON schema,&lt;/li&gt;
&lt;li&gt;it has at least the minimum film count, and&lt;/li&gt;
&lt;li&gt;every poster file it points to actually exists on disk.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no language model in that list. The regeneration step might use Claude, but the decision about whether the data is good is plain, deterministic code. That is on purpose. You do not want the thing deciding "is this safe to publish" to be a model that could confidently say yes when the answer is no. Regeneration can involve a model; validation should not.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hooks, the piece that ties the series together
&lt;/h2&gt;

&lt;p&gt;The same check is also wired into a Claude Code hook, which is worth explaining, because it is the piece that makes the loops in this series trustworthy.&lt;/p&gt;

&lt;p&gt;A hook is a shell command attached to a point in Claude's lifecycle. This one runs when a session tries to stop, and it can block the stop by exiting non-zero:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node_modules/.bin/tsx scripts/validate-regions.ts &amp;gt;&amp;amp;2 || exit 2"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If a session ends with the data in a broken state, the hook fails the session and the work does not go through. The point is that enforcement does not depend on the model deciding to behave. A plain shell command has the final say. That is the same idea as Part 1's protected-files check and Part 2's read-only reviewer, in a different form.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two ways to run it, and who pays
&lt;/h2&gt;

&lt;p&gt;This is where the loop got practical in a way I did not expect. There are two reasonable ways to run something nightly, and they differ mainly in who pays.&lt;/p&gt;

&lt;p&gt;The cloud way is a GitHub Actions workflow. It runs on GitHub's cron, bakes the data headlessly, runs the gate, and opens a pull request when the data is good or an issue when it is not:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Regenerate datasets (Claude, headless)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;claude -p "Run npm run bake. Baking only." \&lt;/span&gt;
      &lt;span class="s"&gt;--bare --permission-mode acceptEdits --allowedTools "Bash(npm run bake)"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate datasets (deterministic gate)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run validate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;--bare&lt;/code&gt; is the scripted mode. It skips hooks, plugins, and &lt;code&gt;CLAUDE.md&lt;/code&gt; discovery, and it authenticates with an API key. That key is the pay-as-you-go Anthropic API, which has a $5 minimum and no free tier, because a CI runner has no interactive login to fall back on.&lt;/p&gt;

&lt;p&gt;The local way is free. This loop does not actually need a model, since the bake is deterministic and the gate is a plain script, so the free version is a local job that macOS &lt;code&gt;launchd&lt;/code&gt; runs every night. Because the output is byte-for-byte stable, the normal result is a clean no-op:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ korean.json (120 films, all posters present)
✓ tamil.json  (200 films, all posters present)
✓ nothing to refresh — data byte-stable, gate green. Loop cost: ~nothing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  A note on cost that caught me out
&lt;/h3&gt;

&lt;p&gt;I got this wrong at first, so let me state it plainly. There are two separate ways to pay for Claude:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Claude Code subscription (Pro or Max) covers &lt;code&gt;claude&lt;/code&gt; running locally. Loops 1 and 2 use this, which is why Loop 2 stopped on a session limit rather than a bill.&lt;/li&gt;
&lt;li&gt;The Anthropic API key is the metered, pay-as-you-go one. Only a cloud or CI run needs it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I made the local job the default and kept the cloud workflow in the repo as the documented option for a real unattended setup. Same gate, different tradeoff, and it helps to know which one a given loop is spending.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug that broke all three loops
&lt;/h2&gt;

&lt;p&gt;The best bug in the project belongs here, because it did not only hit this loop. It hit all three. The first time any loop ran inside a fresh git worktree, which I used so a runaway loop could not touch my main checkout, it died right away:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sh: playwright: command not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A git worktree does not share &lt;code&gt;node_modules&lt;/code&gt; with your main checkout, because those files are gitignored, and gitignored files are not shared between worktrees. So the loop had no dependencies installed and could not even start. The fix, now in every loop driver, is a one-line guard that installs them if they are missing:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; node_modules/.bin/tsx &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;npm ci&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is where the project's motto comes from: get the harness solid before you make the loop long. Until the environment a loop runs in is reliable, you do not really have a loop. And an unattended job at 3 AM is the worst place to find out your setup was broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I took from Part 3
&lt;/h2&gt;

&lt;p&gt;A scheduled loop succeeds quietly. Most nights it should do nothing and cost almost nothing, and the whole design is really about making sure the one loud failure gets through on the night it matters.&lt;/p&gt;

&lt;p&gt;Here are the three loops side by side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Loop&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;th&gt;Success looks like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1. Goal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;you run it&lt;/td&gt;
&lt;td&gt;a number (FPS) from a test&lt;/td&gt;
&lt;td&gt;the number goes green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2. Maker-checker&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;you run it&lt;/td&gt;
&lt;td&gt;a rubric applied by a separate read-only agent&lt;/td&gt;
&lt;td&gt;the reviewer says APPROVED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3. Scheduled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a timer&lt;/td&gt;
&lt;td&gt;deterministic data validation&lt;/td&gt;
&lt;td&gt;it quietly does nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They all rely on the same rule from Part 1: the thing being checked cannot edit the check, and only a person moves the target. When that holds, the loop is something you can leave running. When it does not, it only looks like it is working, which is worse than not having it.&lt;/p&gt;

&lt;p&gt;And that is the series. The movie site was only ever a way to build and test the loops.&lt;/p&gt;

&lt;p&gt;The full project, including the drivers, gates, agents, and hooks, is on GitHub:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/AnvaySingh" rel="noopener noreferrer"&gt;
        AnvaySingh
      &lt;/a&gt; / &lt;a href="https://github.com/AnvaySingh/moviebrowse" rel="noopener noreferrer"&gt;
        moviebrowse
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;MovieBrowse — a loop-engineering learning project&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A small, deliberately-boring &lt;strong&gt;regional cinema browser&lt;/strong&gt;: a fast, virtualized grid of
film posters for one "region" (a language / film industry, not a country), with a
manual region selector and a geo-IP default.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;But the website is not the point.&lt;/strong&gt; It's a &lt;em&gt;vehicle&lt;/em&gt; for learning and demonstrating
&lt;strong&gt;loop engineering with Claude Code&lt;/strong&gt; — building automated loops where an AI agent makes
a change, a deterministic check decides whether that change is good, and the loop repeats
until the check passes. The interesting part is the loops, not the movie site. If the
website ever became the hard part, something went wrong.&lt;/p&gt;
&lt;p&gt;This repo ships three loops, each a different &lt;em&gt;shape&lt;/em&gt;, each anchored in a real config and
a real failure. There's one write-up per loop in &lt;a href="https://github.com/AnvaySingh/moviebrowse/./blog" rel="noopener noreferrer"&gt;&lt;code&gt;blog/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What is a "loop"?&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;A &lt;strong&gt;loop&lt;/strong&gt; here means:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;run something → check a verifiable&lt;/strong&gt;…&lt;/p&gt;
&lt;/blockquote&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/AnvaySingh/moviebrowse" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;If you build a loop of your own, I would like to hear what you had it check. Feedback and suggestions are always welcome, and thanks for reading all three parts.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>claude</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
