<?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: ULNIT</title>
    <description>The latest articles on DEV Community by ULNIT (@ulnit).</description>
    <link>https://dev.to/ulnit</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%2F592406%2Fd91a2be3-1b3c-43c3-a231-712206ed4013.png</url>
      <title>DEV Community: ULNIT</title>
      <link>https://dev.to/ulnit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ulnit"/>
    <language>en</language>
    <item>
      <title>My AI Agent Ran a 3-Hour Task Perfectly, Then Emailed 40 Customers About Work That Wasn't Done</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Thu, 17 Sep 2026 01:03:53 +0000</pubDate>
      <link>https://dev.to/ulnit/my-ai-agent-ran-a-3-hour-task-perfectly-then-emailed-40-customers-about-work-that-wasnt-done-1n6a</link>
      <guid>https://dev.to/ulnit/my-ai-agent-ran-a-3-hour-task-perfectly-then-emailed-40-customers-about-work-that-wasnt-done-1n6a</guid>
      <description>&lt;p&gt;I run a support-and-ops agent on a Raspberry Pi that handles long, multi-step jobs: triaging inboxes, reconciling order webhooks, writing the daily summary. For weeks it was flawless on anything under an hour. Then one Tuesday it spent three hours on a migration task, and in the final step it did the one thing I had explicitly, in bold, at the top of its instructions, told it never to do.&lt;/p&gt;

&lt;p&gt;It emailed 40 customers about a change that hadn't shipped yet.&lt;/p&gt;

&lt;p&gt;No tool bug. No hallucinated fact. No prompt regression. The rule was still sitting in my system prompt, exactly where I'd written it. The problem was that by hour three, the model could no longer &lt;em&gt;see&lt;/em&gt; it — because the runtime had quietly summarized the first 80% of the conversation away to fit the context window, and summaries, it turns out, are lossy in the worst possible direction: they keep the recent and the loud, and drop the quiet constraints stated once, long ago.&lt;/p&gt;

&lt;p&gt;This is a post-mortem of that failure and the architecture that replaced it. If you run agents on tasks longer than a single context window, you will hit this. I'd rather you hit it reading this than hitting it on your customer list.&lt;/p&gt;

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

&lt;p&gt;My agent loop was bog-standard: system prompt + task, then tool calls and results accumulating in one conversation until done. The migration task involved ~200 tool calls — file reads, dry-run outputs, a database diff. Around call 140, my provider's context management kicked in and compacted the older turns into a summary so the run could continue.&lt;/p&gt;

&lt;p&gt;Three things got lost in that compaction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The constraint.&lt;/strong&gt; "Do NOT send any customer emails until the migration is verified and I approve manually" was stated once, in the system prompt, ~3 hours and 100k tokens earlier. The compaction treated the whole conversation as one blob. Early instructions got folded into a two-paragraph summary that captured &lt;em&gt;what the agent was doing&lt;/em&gt; but not &lt;em&gt;what it was forbidden from doing&lt;/em&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The verification step.&lt;/strong&gt; The plan had a "verify checksums, then wait for approval" gate. After compaction, the agent's working memory said something like "migration mostly complete, remaining step: notify customers." The gate wasn't remembered as a gate — it was remembered as, at best, a suggestion.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Its own uncertainty.&lt;/strong&gt; Before compaction, the agent had noted "diff shows 3 unresolved conflicts." That note got summarized into "migration proceeding normally." This is the scariest loss: the summary didn't just drop detail, it dropped &lt;em&gt;doubt&lt;/em&gt;, and doubt is what makes agents check before acting.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the agent finished the last file operation, saw "notify customers" as the only remaining step in its compressed memory, and cheerfully sent 40 emails about a migration that was in a broken intermediate state. I caught it in 20 minutes and spent the next day sending corrections. Reputation cost: real but survivable. Nerve cost: significant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive fixes, and why they failed
&lt;/h2&gt;

&lt;p&gt;Before I got to the real fix, I tried two obvious things. Both failed in instructive ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix attempt #1: LOUDER PROMPT.&lt;/strong&gt; I moved the constraint to the top, capitalized it, added "CRITICAL" and "NEVER." It survived slightly longer into the run — and then got compacted anyway, because compaction doesn't care about your font choices. It cares about token position and recency. If your safety rule lives only in text that will eventually be summarized, it has an expiration date. &lt;strong&gt;Lesson: emphasis is not persistence.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix attempt #2: just use a bigger context window.&lt;/strong&gt; I switched to a model with 4x the context. This "worked" for two weeks and then failed the same way on a bigger task — and every run got slower and ~3x more expensive. &lt;strong&gt;Lesson: a bigger window doesn't fix the architecture, it just moves the cliff.&lt;/strong&gt; Any fixed window eventually meets a longer task. And if your agent's memory is the conversation itself, every failure mode of conversations — drift, dilution, compaction — becomes a failure mode of your agent's state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: state lives on disk, not in the conversation
&lt;/h2&gt;

&lt;p&gt;The mental shift that solved this: &lt;strong&gt;the conversation is a scratchpad, not the memory of record.&lt;/strong&gt; Anything that must survive hour three has to live outside the context window, in a place the agent re-reads on every loop.&lt;/p&gt;

&lt;p&gt;I now run long tasks with three files:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;constitution.md&lt;/code&gt; — constraints that are re-injected every loop.&lt;/strong&gt; Not appended to history: literally prepended to the model input at the &lt;em&gt;start of every iteration&lt;/em&gt;, so they're always in the most recent, never-compacted region of the window. Ten to fifteen lines max, or it stops being a constitution and becomes another document the model skims.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# INVARIANT RULES (never summarizable, always current)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; No customer-facing sends without explicit approval flag in state.json
&lt;span class="p"&gt;-&lt;/span&gt; No money movement above $5 without approval flag
&lt;span class="p"&gt;-&lt;/span&gt; If state.json says verified=false, the task is NOT complete
&lt;span class="p"&gt;-&lt;/span&gt; When unsure whether a step is allowed: stop and write the question to state.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;state.json&lt;/code&gt; — the single source of truth about task progress.&lt;/strong&gt; The agent updates it after every meaningful step, and reads it before every decision. Compaction can shred the conversation; it can't touch the file.&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;"task"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders_migration_sept"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"migrated_unverified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"approval"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"open_conflicts"&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="s2"&gt;"row 1184"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"row 2210"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"row 3977"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"resolve conflicts, run checksum verify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"notes_for_future_self"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"diff tool truncates at 50 rows — re-run with --full"&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;Two fields did most of the work: &lt;code&gt;verified&lt;/code&gt; (a boolean gate the constitution references by name, so losing the prose doesn't lose the rule) and &lt;code&gt;notes_for_future_self&lt;/code&gt;, which is where the agent writes down its doubts. That last field directly addresses failure #3 above — after compaction the agent no longer &lt;em&gt;remembers&lt;/em&gt; being uncertain, but it can &lt;em&gt;read&lt;/em&gt; that it was uncertain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A checkpoint ritual.&lt;/strong&gt; Every 30 minutes of wall-clock time, the agent appends a summary of what it did and what changed to &lt;code&gt;log.md&lt;/code&gt;, on disk. If the run crashes, gets compacted badly, or I kill it and restart, the fresh agent reads &lt;code&gt;constitution.md&lt;/code&gt; + &lt;code&gt;state.json&lt;/code&gt; + the tail of &lt;code&gt;log.md&lt;/code&gt; and resumes in the right mental state within one tool call. The conversation history becomes disposable — which is exactly what you want, because it was never reliable in the first place.&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 python"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;constitution.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# always fresh, never compacted
&lt;/span&gt;        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;CURRENT STATE:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;RECENT LOG:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;log.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Continue the task.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_agent_turn&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;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# agent's tool calls update state.json / log.md on disk
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what's &lt;em&gt;not&lt;/em&gt; in there: the full conversation. Each turn is nearly stateless. The model gets the rules, the truth, and the recent past — reconstituted from disk every time. Compaction stopped mattering because there was nothing left worth compacting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost, and what I'd do differently
&lt;/h2&gt;

&lt;p&gt;Honest accounting, because the setup isn't free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More tool calls per turn.&lt;/strong&gt; Reading three files every loop adds latency and tokens. In practice it made runs &lt;em&gt;cheaper&lt;/em&gt; overall, because I stopped replaying 100k-token conversations and the agent stopped redoing work it had forgotten completing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent sometimes writes bad state.&lt;/strong&gt; Early on it would mark &lt;code&gt;"verified": true&lt;/code&gt; optimistically. I fixed that the boring way: the verify step is a &lt;em&gt;script&lt;/em&gt;, not an agent judgment call, and only the script writes the &lt;code&gt;verified&lt;/code&gt; field. The agent can request verification; it can't grant it. If you take one thing from this post, take this: &lt;strong&gt;make critical state transitions the output of deterministic code, not model opinion.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I should have started here.&lt;/strong&gt; In hindsight, the three-file pattern is not a "fix" I bolted on after a failure — it's the minimum viable architecture for any agent task longer than ~an hour, and I should have assumed day one that every long run eventually gets compacted, truncated, or restarted. Design for an agent with amnesia that can read its own notebook, not an agent that remembers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 40 customers got a correction email within a day, and three of them replied with some version of "happens, your bot was at least honest about it." I'll take it.&lt;/p&gt;

&lt;p&gt;The deeper point generalizes well past this one bug: your agent's reliability is bounded by wherever its state lives. If state lives in the conversation, it inherits every weakness of the conversation. Put the rules and the truth on disk, re-read them every loop, and let the model forget everything else — it was only ever a scratchpad anyway.&lt;/p&gt;

&lt;p&gt;I write up the specific playbooks in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/0ce2371c-c75d-423c-b64d-685a00445048" rel="noopener noreferrer"&gt;The Solo Operator's AI Agent Playbook&lt;/a&gt; — code LAUNCH90 at checkout makes it $1.90. If it doesn't save you 5 hours in week one, reply to the receipt for a refund.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Replaced My Agent's 900-Word Instruction Prompt With 6 Examples. It Started Confidently Selling a Product I Retired in 2024.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Wed, 16 Sep 2026 01:03:18 +0000</pubDate>
      <link>https://dev.to/ulnit/i-replaced-my-agents-900-word-instruction-prompt-with-6-examples-it-started-confidently-selling-a-37ck</link>
      <guid>https://dev.to/ulnit/i-replaced-my-agents-900-word-instruction-prompt-with-6-examples-it-started-confidently-selling-a-37ck</guid>
      <description>&lt;p&gt;My support agent ran on a 900-word instruction prompt that I'd been polishing for two months. Every time it made a mistake, I added another rule. "Never promise refunds above $50 without flagging." "If the customer is angry, acknowledge first." "When unsure about shipping times, say 5–7 business days, not 3–5."&lt;/p&gt;

&lt;p&gt;Nine hundred words of rules, and the agent still occasionally sounded like a lawyer apologizing for existing.&lt;/p&gt;

&lt;p&gt;So in July I tried the thing every prompt engineering article tells you to do: I deleted most of the instructions and replaced them with six hand-written examples of ideal responses. Few-shot prompting. Supposedly a slam dunk.&lt;/p&gt;

&lt;p&gt;It got worse immediately — in a way I didn't understand for about a week. This post is what I learned: why examples beat instructions &lt;em&gt;most&lt;/em&gt; of the time, the specific failure mode nobody warned me about, and the structure I ended up with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why examples beat instructions (when they work)
&lt;/h2&gt;

&lt;p&gt;Instructions describe the target. Examples &lt;em&gt;are&lt;/em&gt; the target. A model doesn't have to interpret "be concise but warm" — those words map to a fuzzy region of behavior space. But a concrete example of a concise-but-warm reply pins the tone, length, and format exactly, with zero interpretation overhead.&lt;/p&gt;

&lt;p&gt;Three things improved the day I switched:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Format compliance went from ~85% to ~99%.&lt;/strong&gt; I'd been begging the agent (in prose) to always end shipping questions with the tracking-link pattern. One example that ended that way did more than three paragraphs of rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Tone stopped drifting.&lt;/strong&gt; With instructions, tone depended on which rules the model happened to weight on a given run. With examples, the tone was anchored. Every output sounded like it came from the same human.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The prompt got shorter and cheaper.&lt;/strong&gt; Six examples plus 120 words of framing cost fewer tokens than my 900-word rulebook, and I could finally read the whole prompt in one screen.&lt;/p&gt;

&lt;p&gt;If that were the whole story, this would be a boring post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure: my agent started hallucinating my examples
&lt;/h2&gt;

&lt;p&gt;Here's what nobody told me. On day three, a customer asked about a product I don't even sell anymore — an old plan called "Starter Tier" that I'd retired in 2024. One of my six examples happened to mention Starter Tier pricing, because I'd written the examples from real (but old) support threads.&lt;/p&gt;

&lt;p&gt;The agent replied, confidently, with the retired plan's price. The customer tried to buy it. I got an email asking why checkout didn't work.&lt;/p&gt;

&lt;p&gt;That was the visible failure. The invisible one was worse: I checked the logs and found the agent had been &lt;em&gt;pattern-matching my examples as facts&lt;/em&gt; for three days. Any question that was even loosely near an example got answered with details from the example — including numbers, timeframes, and policy specifics that were frozen in whatever moment I'd written them.&lt;/p&gt;

&lt;p&gt;I had accidentally created a tiny, authoritative-looking knowledge base of stale facts and told the model "responses look like this." The model heard "these are true things."&lt;/p&gt;

&lt;p&gt;The root cause, once I saw it: &lt;strong&gt;examples carry two kinds of information — style and content — and the model can't tell which parts you mean as demonstration and which as ground truth.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: separate style from facts
&lt;/h2&gt;

&lt;p&gt;I restructured the prompt into three explicit layers, and this is the part I'd hand anyone starting out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LAYER 1 — FACTS (the only source of truth)
Current plans, prices, shipping windows, refund policy.
"Only use information from this section for any specific
number, date, price, or policy. If it's not here, say you'll
check and escalate."

LAYER 2 — STYLE EXAMPLES (demonstrations, not facts)
3–5 example exchanges. Prefaced with:
"These examples demonstrate TONE, LENGTH, and STRUCTURE only.
The prices, plans, and details in them may be fictional.
Never copy specific facts from these examples into replies."

LAYER 3 — ESCALATION RULES (short)
5–10 bullet rules for the genuinely hard cases: angry
customers, refund requests over $X, anything legal.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that mattered more than I expected:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I deliberately made the style examples slightly fictional.&lt;/strong&gt; Changed names, rounded numbers, invented order IDs. If the agent ever leaked example content into a real reply, it would be obviously wrong ("Order #12345") instead of subtly wrong (a real-looking but outdated price). Subtle wrongness is what gets you a refund dispute. Obvious wrongness gets you a caught bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I added one anti-example.&lt;/strong&gt; A single "BAD response" with one line explaining why it's bad — in my case, an over-apologetic three-paragraph reply. Negative examples are underrated; they draw the boundary of the style region from the other side, and they cost almost nothing.&lt;/p&gt;

&lt;p&gt;After the restructure, format compliance stayed at ~99%, tone stayed anchored, and fact hallucinations from examples went to zero in the following eight weeks. Not because the model got smarter — because the prompt finally told it which parts were a demonstration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules I now follow when writing example-driven prompts
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Examples for style, explicit data for facts, never mix them.&lt;/strong&gt; If an example contains a real number, that number will eventually be repeated to someone who shouldn't hear it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit examples like you'd audit dependencies.&lt;/strong&gt; Every example is a frozen snapshot of your business. When prices change, examples go stale silently. I re-read mine on the first of every month — calendar invite, ten minutes, non-negotiable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three to six examples is the sweet spot.&lt;/strong&gt; Below three, the style isn't pinned. Above six, they start contradicting each other in subtle ways and you're paying tokens for noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include at least one hard case.&lt;/strong&gt; Don't make all your examples happy-path. One example where the correct answer is "let me check and get back to you" teaches the agent that not-knowing is an acceptable output — that single example probably prevented more damage than everything else combined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with adversarial inputs before shipping.&lt;/strong&gt; I threw 20 weird real customer emails at the new prompt, including ones near the edges of my examples. That's how I'd have caught the Starter Tier problem on day one instead of day three.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'd tell myself two months ago
&lt;/h2&gt;

&lt;p&gt;Instructions aren't dead — my Layer 3 rules are instructions, and they're load-bearing. The lesson isn't "examples &amp;gt; rules." It's that the two do different jobs, and most broken agent prompts I've seen (including mine) are broken because one is doing the other's job. Rules trying to describe tone produce lawyer-speak. Examples trying to carry facts produce confident lies.&lt;/p&gt;

&lt;p&gt;Give each layer its job, label the layers explicitly, and the model does what you meant instead of what you wrote.&lt;/p&gt;

&lt;p&gt;All 100 prompts are in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/af4c3237-d411-4fbd-87b9-d5a562e55e4c" rel="noopener noreferrer"&gt;The Agent Prompt Vault&lt;/a&gt; — $3, lifetime updates. Steal the ones that fit your workflow.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>My Raspberry Pi's SD Card Died at 2 AM. My "Automatic" Backups Turned Out to Be a 47-Day-Old Lie.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Tue, 15 Sep 2026 01:03:20 +0000</pubDate>
      <link>https://dev.to/ulnit/my-raspberry-pis-sd-card-died-at-2-am-my-automatic-backups-turned-out-to-be-a-47-day-old-lie-13ll</link>
      <guid>https://dev.to/ulnit/my-raspberry-pis-sd-card-died-at-2-am-my-automatic-backups-turned-out-to-be-a-47-day-old-lie-13ll</guid>
      <description>&lt;p&gt;The alert came in at 2:14 AM: three of my automation agents had missed their heartbeat window. By the time I SSHed in from my phone, I already knew — the Pi that runs my entire one-person business stack had stopped responding mid-write. When I finally pulled the power and booted from a fresh card, &lt;code&gt;fsck&lt;/code&gt; gave me the verdict: the filesystem was corrupt beyond repair.&lt;/p&gt;

&lt;p&gt;No problem, I thought. I have nightly backups. I've had them since day one.&lt;/p&gt;

&lt;p&gt;The backup ran every night at 3 AM. It logged success every single night. And when I opened the backup directory, the newest snapshot was &lt;strong&gt;47 days old&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the post-mortem of how a backup system that "worked" for a month and a half was actually broken from roughly week one — and the handful of cheap, boring fixes that made sure it can never lie to me again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the stack was
&lt;/h2&gt;

&lt;p&gt;For context, this Pi runs the unglamorous core of my setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two AI agents (support triage and a content scheduler) running as systemd services&lt;/li&gt;
&lt;li&gt;A small Python job runner with cron-triggered tasks&lt;/li&gt;
&lt;li&gt;An SQLite database that holds customer state, agent memory, and job history&lt;/li&gt;
&lt;li&gt;A reverse proxy in front of two internal web apps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing exotic. Total cost: a $60 Pi, one SD card, and the assumption that "I set up backups" means "I have backups."&lt;/p&gt;

&lt;h2&gt;
  
  
  The backup that wasn't
&lt;/h2&gt;

&lt;p&gt;My backup was a shell script in &lt;code&gt;/etc/cron.daily&lt;/code&gt;. Roughly this:&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="c"&gt;#!/bin/bash&lt;/span&gt;
rsync &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--delete&lt;/span&gt; /home/sean/agents/ /mnt/usb/backup/agents/
sqlite3 /home/sean/agents/state.db &lt;span class="s2"&gt;".backup /mnt/usb/backup/state.db"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;: backup OK"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/backup.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looked correct. It even &lt;em&gt;logged&lt;/em&gt; success. Three separate bugs were stacked on top of each other:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: The USB drive had silently unmounted.&lt;/strong&gt; About 47 days before the crash, the drive dropped off the bus after a brief power dip. The mount point &lt;code&gt;/mnt/usb&lt;/code&gt; still existed — as an empty directory on the SD card. rsync happily wrote into it, consuming SD card space instead of backing anything up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2: The log line always said OK.&lt;/strong&gt; The &lt;code&gt;echo&lt;/code&gt; ran unconditionally. I never checked &lt;code&gt;$?&lt;/code&gt; on the rsync or the sqlite backup. "backup OK" was printed whether or not anything had been copied. I built a system whose only job was to tell me the truth, and I wrote it so it could only tell me one thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 3: Nobody read the log anyway.&lt;/strong&gt; Even a correct log line goes nowhere if the only failure mode is "human remembers to check." I had alerting on agent heartbeats but zero alerting on the thing whose entire purpose was disaster recovery.&lt;/p&gt;

&lt;p&gt;The irony: I monitor my AI agents obsessively. I got paged when an agent missed a heartbeat by six minutes. But the backup script — the thing standing between me and total data loss — could fail silently for seven straight weeks and I'd never know.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: backups must prove themselves
&lt;/h2&gt;

&lt;p&gt;I rebuilt the backup around one principle: &lt;strong&gt;a backup that doesn't verify itself is a rumor, not a backup.&lt;/strong&gt; Here's the current version, trimmed:&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="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;DEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/mnt/usb/backup
&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0

&lt;span class="c"&gt;# 1. Prove the destination is real storage, not an empty mountpoint&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; mountpoint &lt;span class="nt"&gt;-q&lt;/span&gt; /mnt/usb&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;notify &lt;span class="s2"&gt;"BACKUP FAIL: /mnt/usb not mounted"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 2. Prove there's space&lt;/span&gt;
&lt;span class="nv"&gt;AVAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;avail &lt;span class="nt"&gt;-BM&lt;/span&gt; /mnt/usb | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-dc&lt;/span&gt; &lt;span class="s1"&gt;'0-9'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AVAIL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 2000 &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;notify &lt;span class="s2"&gt;"BACKUP FAIL: only &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AVAIL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;MB free"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 3. Copy, and capture the real exit status&lt;/span&gt;
rsync &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--delete&lt;/span&gt; /home/sean/agents/ &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/agents/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
sqlite3 /home/sean/agents/state.db &lt;span class="s2"&gt;".backup &lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/db/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;.db"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# 4. Verify the DB is a readable SQLite file with plausible content&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; sqlite3 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/db/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;.db"&lt;/span&gt; &lt;span class="s2"&gt;"PRAGMA integrity_check;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"^ok$"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;notify &lt;span class="s2"&gt;"BACKUP FAIL: integrity check on &lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;.db"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 5. Only NOW claim success — and post the proof&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FAIL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &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;&lt;span class="nv"&gt;SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/agents/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;CHECKSUM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/db/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;.db"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-12&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  notify &lt;span class="s2"&gt;"backup OK &lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt; size=&lt;/span&gt;&lt;span class="nv"&gt;$SIZE&lt;/span&gt;&lt;span class="s2"&gt; db_sha=&lt;/span&gt;&lt;span class="nv"&gt;$CHECKSUM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;notify &lt;span class="s2"&gt;"BACKUP PARTIAL FAIL on &lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt;&lt;/strong&gt; — the script dies on the first real error instead of strolling to the success line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mountpoint -q&lt;/code&gt;&lt;/strong&gt; — explicitly proves the destination is mounted storage. This one check would have caught all 47 days of failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity check on the copied database&lt;/strong&gt; — &lt;code&gt;PRAGMA integrity_check&lt;/code&gt; catches truncated or corrupt copies, not just missing ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A positive heartbeat, not just negative alerts.&lt;/strong&gt; Every successful run pushes a message (I use a tiny webhook to my phone) containing the date, size, and a checksum prefix. If I don't see that message for 36 hours, &lt;em&gt;something&lt;/em&gt; is wrong even if no error ever fired. Absence of bad news is not news; I made the backup generate actual news.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The part I skipped the first time: restore drills
&lt;/h2&gt;

&lt;p&gt;Here's the second failure, and it's more embarrassing than the first.&lt;/p&gt;

&lt;p&gt;Two weeks after rebuilding the script, I decided to test a restore. The backup was fresh, verified, checksummed, beautiful. And the restore failed — because the backup included the &lt;em&gt;running&lt;/em&gt; SQLite database files copied with plain &lt;code&gt;rsync&lt;/code&gt; in an older snapshot, and more importantly, my agents' config referenced absolute paths and an environment file that lived &lt;strong&gt;outside&lt;/strong&gt; the backed-up directory. I had a complete copy of data and no copy of the thing that makes the data usable.&lt;/p&gt;

&lt;p&gt;The fix was a &lt;code&gt;RESTORE.md&lt;/code&gt; in the repo — a literal runbook:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Flash fresh Raspberry Pi OS Lite&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apt install&lt;/code&gt; the pinned package list (I now generate &lt;code&gt;packages.txt&lt;/code&gt; nightly into the backup)&lt;/li&gt;
&lt;li&gt;Copy &lt;code&gt;.env&lt;/code&gt; from the encrypted secrets folder (which itself was in &lt;em&gt;neither&lt;/em&gt; backup — it's now backed up separately with &lt;code&gt;age&lt;/code&gt; encryption)&lt;/li&gt;
&lt;li&gt;Restore the DB from the newest &lt;code&gt;integrity_check&lt;/code&gt;-passing snapshot&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;systemctl start&lt;/code&gt; on the units, verify heartbeats within 10 minutes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then I did the thing I should have done from day one: &lt;strong&gt;I ran the drill.&lt;/strong&gt; Booted a spare Pi from scratch, followed my own document, hit two outdated steps, fixed them, and got to a fully verified restore in 22 minutes. Now the drill runs the first Sunday of every month, and the timer is in cron like everything else.&lt;/p&gt;

&lt;p&gt;A backup's real metric isn't "did the copy succeed." It's &lt;strong&gt;time-to-restored-service&lt;/strong&gt;. Mine went from "unknown/infinite" to a tested 22 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself six months ago
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Silence is a failure mode.&lt;/strong&gt; Any safety system that only speaks when asked is a system you'll forget to ask. Make it send a heartbeat with &lt;em&gt;content&lt;/em&gt; — size, checksum, timestamp — so a missing or malformed heartbeat is detectable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the destination before writing to it.&lt;/strong&gt; One &lt;code&gt;mountpoint -q&lt;/code&gt; line would have saved 47 days of false confidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untested restores don't exist.&lt;/strong&gt; The restore drill found two gaps the backup script structurally could not find. Run the drill before you need it, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The unmonitored thing will be the thing that kills you.&lt;/strong&gt; I had dashboards for agents, alerts for API spend, and heartbeats for cron jobs. The backup ran outside all of it, because it "just worked." That phrase should be a lint error in every ops setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up the environment, not just the data.&lt;/strong&gt; DBs and files are half a system. Pinned package lists, env/secrets (encrypted), and a written restore runbook are the other half.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total cost of the rebuild: one $12 USB drive kept in a different room, about 90 minutes of work, and one monthly 20-minute drill. The SD card that died cost me an evening — instead of the business.&lt;/p&gt;

&lt;p&gt;The deeper lesson, and the one that applies way beyond Raspberry Pis: every automation you run — agents included — needs the same treatment. Prove it ran, prove the output is valid, and rehearse the recovery while nothing is on fire.&lt;/p&gt;

&lt;p&gt;I write up the specific playbooks in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/0ce2371c-c75d-423c-b64d-685a00445048" rel="noopener noreferrer"&gt;The Solo Operator's AI Agent Playbook&lt;/a&gt; — code LAUNCH90 at checkout makes it $1.90. If it doesn't save you 5 hours in week one, reply to the receipt for a refund.&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>automation</category>
      <category>productivity</category>
      <category>ai</category>
    </item>
    <item>
      <title>My AI Agent Tried to Install a Typosquatted Package at 3 AM. It Was One Dependency Conflict Away From Owning My Keys.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Mon, 14 Sep 2026 01:03:05 +0000</pubDate>
      <link>https://dev.to/ulnit/my-ai-agent-tried-to-install-a-typosquatted-package-at-3-am-it-was-one-dependency-conflict-away-33f9</link>
      <guid>https://dev.to/ulnit/my-ai-agent-tried-to-install-a-typosquatted-package-at-3-am-it-was-one-dependency-conflict-away-33f9</guid>
      <description>&lt;p&gt;My agent runs on a Raspberry Pi 4, and for the first two months I let it do whatever it needed to do — including installing its own Python dependencies. It was great. It unblocked itself constantly. I'd wake up to notes like "installed &lt;code&gt;feedparser&lt;/code&gt; to handle the RSS task, working now."&lt;/p&gt;

&lt;p&gt;Then one Tuesday I looked at an install log and saw a package name I didn't recognize. The agent had tried to install &lt;code&gt;reqeusts&lt;/code&gt; — a typosquat of &lt;code&gt;requests&lt;/code&gt; — because it had hallucinated the spelling from a scraped Stack Overflow answer, and something on PyPI was waiting for exactly that mistake.&lt;/p&gt;

&lt;p&gt;The install failed on a dependency conflict. Pure luck. If it had succeeded, whatever was in that package would have run, with my agent's permissions, on a box that holds API keys for my payment processor, my email, and my DNS provider.&lt;/p&gt;

&lt;p&gt;This post is what I changed afterward: how to let an AI agent install software without handing strangers a shell on your infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core problem: agents are gullible installers
&lt;/h2&gt;

&lt;p&gt;A human developer typo-squats rarely because they've installed &lt;code&gt;requests&lt;/code&gt; five hundred times and their fingers know the spelling. An agent doesn't have fingers. It reconstructs package names from context — scraped docs, training data, a README it read three tool calls ago. Every reconstruction is a chance to produce &lt;code&gt;reqeusts&lt;/code&gt;, &lt;code&gt;python-dotenv&lt;/code&gt;, &lt;code&gt;colorsama&lt;/code&gt;, or any of the other names that attackers register and seed with malware.&lt;/p&gt;

&lt;p&gt;And the agent has three strikes against it that a human doesn't:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It never sees the install page.&lt;/strong&gt; A human glancing at PyPI notices "last release 2019, 4 downloads, one maintainer." The agent just runs the command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It installs at 3 AM.&lt;/strong&gt; There's no second pair of eyes, ever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It has a reason to say yes.&lt;/strong&gt; The agent's job is to complete the task. "Install the package" is the path of least resistance to "task complete." Refusing to install something requires a rule it was explicitly given.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I built instead: a package broker
&lt;/h2&gt;

&lt;p&gt;I didn't ban installs — banned things get worked around, and I'd rather have the agent &lt;em&gt;ask&lt;/em&gt; than get creative. Instead every install goes through a small broker script. The agent calls &lt;code&gt;pkg_install(name)&lt;/code&gt; as a tool; it never touches pip directly. The pip binary on the box is wrapped so direct invocation fails with a message telling the agent to use the tool.&lt;/p&gt;

&lt;p&gt;The broker does five checks before anything touches disk:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Exact-name allowlist.&lt;/strong&gt; I maintain a flat file of ~40 packages my workflows legitimately need. Anything on the list installs immediately, no friction. The list covers 95% of real requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Fuzzy-match the rejection.&lt;/strong&gt; If the requested name isn't allowed but is within edit distance 2 of an allowed package, the broker doesn't just say no — it says &lt;em&gt;"rejected: &lt;code&gt;reqeusts&lt;/code&gt; is not on the allowlist and looks like a typo of &lt;code&gt;requests&lt;/code&gt;. Did you mean &lt;code&gt;requests&lt;/code&gt;?"&lt;/em&gt; This single change killed almost all hallucinated names. The agent corrects itself and retries with the right spelling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Age and popularity gate for new packages.&lt;/strong&gt; If the name is genuinely new (not a near-miss of anything allowed), the broker queries the PyPI JSON API and refuses anything where: first release &amp;lt; 90 days ago, or the project has no homepage/repository URL, or the maintainer has exactly one package that appeared this week. Typosquats almost always trip at least one of these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Human approval queue for the rest.&lt;/strong&gt; Anything that passes the automated gates but isn't already allowed goes into a queue I check once a day from my phone. Approval adds the package to the allowlist permanently, so I only ever evaluate a given package once. Average wait for the agent: a few hours. I've never had a task where that mattered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Full audit log.&lt;/strong&gt; Every request — allowed, auto-rejected, or queued — is appended to a JSONL file with the agent's stated reason for wanting the package. More on why this mattered below.&lt;/p&gt;

&lt;p&gt;The whole broker is about 120 lines of Python. The checks aren't clever; the point is that they're &lt;em&gt;between&lt;/em&gt; the agent and the network, and the agent can't route around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sandboxing the install itself
&lt;/h2&gt;

&lt;p&gt;Even an allowed package runs arbitrary code at install time (setup scripts) and at import time. For my setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent's whole runtime lives in a dedicated user account with no sudo, and its tool sandbox runs as that user.&lt;/li&gt;
&lt;li&gt;API keys are not in its environment. They live behind a tiny local proxy that attaches credentials to outbound requests the agent is allowed to make. A malicious package inside the agent's process can use the proxy for whitelisted endpoints; it cannot read the raw keys to exfiltrate them elsewhere.&lt;/li&gt;
&lt;li&gt;The Pi's outbound traffic goes through a firewall rule that only allows the specific hosts the proxy and the agent need. An installed package that phones home to a random C2 domain fails at the network layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third one is the check I'd add first if you add nothing else. Allowlist egress on any machine where an LLM can execute code. It converts "malware installed" from a catastrophe into an alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that taught me the most
&lt;/h2&gt;

&lt;p&gt;Two weeks after the broker went live, I checked the audit log and found eleven requests in one night for a package called &lt;code&gt;pytelegrambotapi-async-helper&lt;/code&gt; — not on my allowlist, not a typo of anything on it. The agent's stated reason, logged verbatim each time: &lt;em&gt;"needed to send the daily report; install rejected; retrying with same package in case of transient error."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two lessons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry-stupidity is real.&lt;/strong&gt; The agent treated a policy rejection like a network flake. I'd written the broker's rejection messages politely ("this package is not currently allowed") and the agent read politeness as ambiguity. I changed the rejection text to be blunt and final: &lt;em&gt;"POLICY REJECTION — do not retry. Use an allowed package or explain the need to the operator in your report."&lt;/em&gt; Retries dropped to zero. If your agent loops on a rejection, your error messages are too nice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It was trying to solve a problem I'd created.&lt;/strong&gt; I'd removed its old notification method in a refactor that day and never gave it a replacement. It wasn't misbehaving; it was resourceful with bad tools. Once I added an allowed &lt;code&gt;notify&lt;/code&gt; tool, that class of request disappeared entirely. The audit log is what connected the two facts — without the "reason" field I'd have just seen a suspicious package name and wondered what was wrong with my agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently from day one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with egress filtering, not package rules.&lt;/strong&gt; Network allowlisting is dumb, robust, and catches problems you didn't think to write rules for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the agent's &lt;em&gt;reason&lt;/em&gt; for every sensitive action&lt;/strong&gt;, not just the action. The action tells you what happened; the reason tells you what the agent was actually trying to do, which is usually something legitimate you can support properly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make policy rejections sound like policy.&lt;/strong&gt; Anything that reads like a soft failure will be retried.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't assume hallucination only affects prose.&lt;/strong&gt; Package names, CLI flags, config keys — anything the model reconstructs from memory is a typo candidate, and typos in prose are embarrassing while typos in &lt;code&gt;pip install&lt;/code&gt; are supply-chain incidents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent still installs what it needs. It just does it through a door I control, and in eight months the door has caught three typosquats and zero legitimate packages. That's a good trade.&lt;/p&gt;




&lt;p&gt;The full checklist + scripts are in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/763b023d-bfb5-475d-ab28-9ba0e9ba142d" rel="noopener noreferrer"&gt;Ship Safe — The Launch-Day Security Kit&lt;/a&gt; — code LAUNCH90 at checkout makes it $1.50.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you run an agent that can execute code, check your install logs today for package names you don't recognize. Finding nothing is the expected result — and the reason to keep the broker in place anyway.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>automation</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>I Started Version-Controlling My AI Agent's Prompts Like Production Code. The First "Improved" Prompt Broke Three Days of Silent Work.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Sun, 13 Sep 2026 01:03:23 +0000</pubDate>
      <link>https://dev.to/ulnit/i-started-version-controlling-my-ai-agents-prompts-like-production-code-the-first-improved-4lc</link>
      <guid>https://dev.to/ulnit/i-started-version-controlling-my-ai-agents-prompts-like-production-code-the-first-improved-4lc</guid>
      <description>&lt;p&gt;I started version-controlling my AI agent's prompts like production code. The first "improved" prompt broke three days of silent work.&lt;/p&gt;

&lt;p&gt;For months my prompts lived wherever they happened to be written: inline strings in Python scripts, a notes app, one in a README I'd forgotten about, and at least two in my shell history. It worked — right up until it didn't, and when it didn't, I had no way to answer the only question that mattered: &lt;em&gt;what changed?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the system I built after that, the failure that forced it, and the parts I'd skip if I were starting over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure: a "better" prompt that wasn't
&lt;/h2&gt;

&lt;p&gt;I run a small support-triage agent on a Raspberry Pi. It reads incoming messages, classifies them, and drafts replies for me to approve. One evening I pasted in a prompt I'd found online — longer, more structured, full of role-setting language. It looked professional. I swapped it in, watched a couple of outputs, thought "seems fine," and went to bed.&lt;/p&gt;

&lt;p&gt;Three days later I noticed the drafts had a strange quality: they were polite, thorough, and completely useless for urgent issues. The agent had stopped flagging anything as high-priority. Every ticket got the same calm, apologetic, "we'll look into this shortly" treatment. One customer with a genuinely broken payment waited two days for a human to notice.&lt;/p&gt;

&lt;p&gt;The new prompt had a line in it something like &lt;em&gt;"remain calm and reassuring at all times; avoid alarming the customer."&lt;/em&gt; The model had interpreted that as "never say anything is urgent." A single clause, buried in paragraph six of a prompt I'd copied without diffing against the old one.&lt;/p&gt;

&lt;p&gt;Here's what actually stung: I couldn't tell you what the &lt;em&gt;old&lt;/em&gt; prompt said. It was gone — overwritten in the script. No history, no diff, no record of what behavior I'd lost. I spent an evening reconstructing it from memory and a stale backup, and the reconstructed version was measurably worse at classification than the original had been. I never fully got it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The system: prompts are code, treat them like code
&lt;/h2&gt;

&lt;p&gt;After that I moved every prompt into a git repo with three rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1: One prompt per file, named for the job.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompts/
  triage-classify.md
  triage-draft-reply.md
  recon-summarize-findings.md
  digest-morning-brief.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each file is pure Markdown with a small YAML header:&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="nn"&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;triage-draft-reply&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
&lt;span class="na"&gt;last_changed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-08-30&lt;/span&gt;
&lt;span class="na"&gt;owner_task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reply drafting only — never classifies priority&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;owner_task&lt;/code&gt; line matters more than it looks. Half my early prompt bugs came from one prompt quietly doing two jobs. When the drafting prompt started classifying priority as a side effect, nobody had decided that — it just happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2: Never edit a prompt without a fixture run.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For each prompt I keep 5–10 canned inputs with expected properties of the output. Not unit tests with exact string matches — that's brittle nonsense with LLMs. Instead, cheap property checks:&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;check_urgent_flag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# the angry-payment-failed fixture MUST be classified urgent
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urgent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_low_priority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urgent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;FIXTURES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fixtures/angry_payment.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_urgent_flag&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fixtures/spam_nigerian_prince.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_low_priority&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fixtures/normal_question.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_no_apology_storm&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third fixture exists specifically because of the failure above. I added an assertion that catches apology-storm output — five consecutive hedging phrases — and it has caught two regressions since.&lt;/p&gt;

&lt;p&gt;The whole suite costs me about four cents to run. I run it before every prompt change, in a pre-commit hook, so I literally cannot ship a prompt edit without the fixtures passing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 3: The commit message says what behavior changed, not what text changed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git diff&lt;/code&gt; tells me the text. The commit message tells me &lt;em&gt;why&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v6-&amp;gt;v7: stop the model from apologizing before answering.
Added "no preamble, first sentence = the answer".
Fixture: normal_question now passes apology check.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three months later, when something feels off, &lt;code&gt;git log prompts/triage-draft-reply.md&lt;/code&gt; reads like a behavioral changelog. When I broke things in August, I had nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the system caught that I didn't expect
&lt;/h2&gt;

&lt;p&gt;The fixtures aren't just regression insurance — they surfaced problems that had been quietly costing me for weeks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model drift.&lt;/strong&gt; My provider updated a model version and my classification accuracy on the fixture set dropped from 9/10 to 6/10 overnight. Because I had the fixtures, I knew it was the model, not my prompt, and I pinned the old version within an hour. Without them I'd have spent a day "improving" a prompt that was fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A prompt that had quietly doubled in size.&lt;/strong&gt; Every time I patched an edge case, I added a sentence. Version 4 of the drafting prompt was 700 words of accumulated scar tissue. Reading the diff history made the bloat obvious, and I rewrote it down to 200 words — which fixture-tested &lt;em&gt;better&lt;/em&gt; than the long version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two prompts that disagreed with each other.&lt;/strong&gt; My triage prompt told the agent to be concise; my drafting prompt told it to be thorough. The agent was resolving that contradiction differently on different days. Neither prompt was wrong; the &lt;em&gt;pair&lt;/em&gt; was. You only see that when all prompts live in one repo, side by side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently / what I got wrong
&lt;/h2&gt;

&lt;p&gt;Honest part, because this system isn't free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I over-engineered the first version.&lt;/strong&gt; My initial setup had a database of prompt versions, a diff UI, and "rollbacks" as first-class objects. It took a weekend to build and I abandoned it in two weeks. Git already does all of it. The version that survived is a folder of Markdown files and a 60-line test script. If you take one thing from this article: &lt;em&gt;the boring version is the version that lasts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixtures rot.&lt;/strong&gt; Three of my original ten fixtures encoded assumptions that stopped being true (an old pricing tier, a retired feature). Stale fixtures that fail for the wrong reason train you to ignore failures — the exact failure mode the system exists to prevent. I now review the fixture set monthly, same as I'd review tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't fix prompt quality.&lt;/strong&gt; Version control tells you what changed and whether behavior regressed. It does not make prompts good. For a long time I mistook "I can see the history of this bad prompt" for "this prompt is good." The improvements came from actually studying which phrasings worked, one experiment at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's overkill below ~5 prompts.&lt;/strong&gt; If you have one agent with one prompt, a &lt;code&gt;.txt&lt;/code&gt; file and discipline is enough. The system pays for itself when prompts multiply and you can no longer hold them all in your head — which, for me, happened around prompt number seven.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The failure that started this wasn't a model failure. It was an ops failure: I changed production behavior with no diff, no history, and no test. Every developer already knows why that's reckless with code. Prompts are the same thing — executable instructions that shape real behavior — they just don't feel like code because they're written in English.&lt;/p&gt;

&lt;p&gt;Put them in git. Write five fixtures each. Make the commit message about behavior. That's the whole system, and it takes an afternoon.&lt;/p&gt;

&lt;p&gt;Collecting and refining prompts is the part that actually takes time — I keep every prompt that survived fixture-testing in one place, organized by job, along with the notes on &lt;em&gt;why&lt;/em&gt; each phrasing works. All 100 prompts are in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/af4c3237-d411-4fbd-87b9-d5a562e55e4c" rel="noopener noreferrer"&gt;The Agent Prompt Vault&lt;/a&gt; — $3, lifetime updates. Steal the ones that fit your workflow.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Made My AI Agent Ask Permission Before Spending or Sending Anything. By Day 3 I Was Ready to Remove It.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Sat, 12 Sep 2026 01:03:19 +0000</pubDate>
      <link>https://dev.to/ulnit/i-made-my-ai-agent-ask-permission-before-spending-or-sending-anything-by-day-3-i-was-ready-to-5gfi</link>
      <guid>https://dev.to/ulnit/i-made-my-ai-agent-ask-permission-before-spending-or-sending-anything-by-day-3-i-was-ready-to-5gfi</guid>
      <description>&lt;p&gt;Last month I wrote about an agent that charged a customer twice in eleven seconds. The fix wasn't a better model or a cleverer prompt — it was a rule I resisted for weeks because it felt like admitting defeat:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing that moves money or leaves the building happens without a human saying yes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I built an approval gate. Every action my agents take gets classified: read-only stuff runs free, anything that sends an email, posts publicly, calls a payment API, or touches production config gets parked in a queue until I approve it from my phone.&lt;/p&gt;

&lt;p&gt;Day 1 felt like genius. Day 3 I had 47 pending approvals, I was rubber-stamping them without reading, and I realized I had rebuilt the exact problem I was trying to solve — just with extra steps.&lt;/p&gt;

&lt;p&gt;Here's what actually worked after I redesigned it, including the part where I nearly gave myself approval fatigue so bad it became worse than no gate at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version (don't do this)
&lt;/h2&gt;

&lt;p&gt;My first implementation was dead simple. Before any "risky" tool call, the agent writes a row to a SQLite table:&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;RISKY_TOOLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post_to_social&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create_refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge_card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deploy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write_production_db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;request_approval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ticket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO approvals (id, agent, tool, payload, status, created_at) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VALUES (?, ?, ?, ?, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, datetime(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;now&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;))&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;notify_me&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# push notification
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ticket_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent loop then polls for a decision:&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;ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;request_approval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_name&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;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wait_for_decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 1 hour
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action required approval: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a 30-line Flask endpoint on my Pi lets me approve or deny from my phone. That's the whole system. It works. It is also, as configured above, a machine for generating 40+ notifications a day, because I had classified actions by &lt;em&gt;tool&lt;/em&gt; instead of by &lt;em&gt;consequence&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why day 3 almost broke it
&lt;/h2&gt;

&lt;p&gt;Three things went wrong, and all three were my fault:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;I gated reads that looked like writes.&lt;/strong&gt; My support agent's &lt;code&gt;send_email&lt;/code&gt; tool was used for both customer replies &lt;em&gt;and&lt;/em&gt; an internal daily digest. Every digest needed approval. Pointless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-stakes actions drowned high-stakes ones.&lt;/strong&gt; A $3 refund request and a "post this to X" request arrived in the same channel with the same urgency. After two days I stopped reading the payloads. I was approving on muscle memory — which is exactly the failure mode an approval gate exists to prevent. A gate you don't read is theater.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No timeout policy meant silent stalls.&lt;/strong&gt; When I forgot my phone, agents sat blocked for hours. Customers got replies at 11 PM. The gate had made the product worse, not safer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The honest lesson: &lt;strong&gt;an approval system is a UX problem, not a security problem.&lt;/strong&gt; If approving is annoying, you will degrade your own controls within a week. I've watched myself do it in real time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The redesign: tier by consequence, not by tool
&lt;/h2&gt;

&lt;p&gt;I now classify every action into three tiers, and only one of them involves me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1 — Reversible and internal.&lt;/strong&gt; Run free. Reading data, drafting content to a staging area, internal DB writes that have an undo path. No notification, no wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2 — Reversible but external.&lt;/strong&gt; Auto-approve with a delay and an audit log. Example: routine support replies. The agent sends, but the message goes out through a queue with a 10-minute delay, and a second cheap LLM pass flags anything that mentions refunds, pricing, or legal language. Flagged items get promoted to Tier 3. In practice ~4% of messages get flagged, and the flagger has caught real problems twice — once when an agent promised a "lifetime" discount it had no authority to offer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3 — Irreversible or money-moving.&lt;/strong&gt; Hard block until human approval. Charges, refunds above $5, public posts, deploys, production schema changes, anything touching credentials. These get a push notification with a diff-style summary: what will happen, to whom, for how much, and &lt;em&gt;why the agent thinks it should&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The tiering logic is just a function — no framework needed:&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;tier_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&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;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge_card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deploy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write_production_db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create_refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;  &lt;span class="c1"&gt;# queue + flagger may promote to 3
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post_to_social&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numbers matter less than the principle: &lt;strong&gt;tier by blast radius and reversibility, not by which function got called.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three details that made it survivable
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Batched approvals.&lt;/strong&gt; Tier 3 requests now arrive in a twice-daily digest unless they're flagged urgent (a live customer waiting, a deploy window). My approval count went from ~47/day to ~6/day, and — this is the part that surprised me — I actually &lt;em&gt;read&lt;/em&gt; them again. Friction per item went up; total friction went down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expiry with a safe default.&lt;/strong&gt; Every pending approval expires after 4 hours and defaults to &lt;em&gt;deny&lt;/em&gt;, and the agent is required to tell the customer "this is taking longer than usual, I'll follow up" rather than going silent. A stale approval queue is a lying queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent must argue its case.&lt;/strong&gt; The approval payload includes the agent's reasoning and the specific evidence it used (order ID, email thread, exact amount). Writing that field forced me to make the agent's context inspectable — which paid off separately, because it's how I caught a hallucinated order number in week two. If the agent can't cite evidence for an irreversible action, that's a deny on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, honestly
&lt;/h2&gt;

&lt;p&gt;Latency. Tier 3 actions can wait hours, and if you're building something customer-facing you need the "I'll follow up" paths to be real. I've lost the odd sale to a slow approval. I'll take that over the alternative — the double-charge incident cost me more in trust (and one very reasonable but very public complaint) than every delayed approval combined.&lt;/p&gt;

&lt;p&gt;Maintenance. The tier function is policy, and policy drifts. Once a month I read the audit log end-to-end and ask two questions: did anything Tier 1/2 do damage? Did anything Tier 3 get denied that shouldn't have been? Both answers change the tiers. It's maybe an hour a month and it's the highest-leverage hour in my whole agent stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you build one thing from this post
&lt;/h2&gt;

&lt;p&gt;Don't gate tools. Gate consequences. Write down every action your agent can take, score each one "can I undo this in under five minutes?" and "does this touch money, reputation, or credentials?" — and put a human in the loop &lt;em&gt;only&lt;/em&gt; where both answers are bad. Then design the approval UX as if your future tired self is the user, because your future tired self is definitely the user, and a tired approver is an auto-approver.&lt;/p&gt;

&lt;p&gt;The gate is not the point. Reading the gate is the point.&lt;/p&gt;

&lt;p&gt;I write up the specific playbooks in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/0ce2371c-c75d-423c-b64d-685a00445048" rel="noopener noreferrer"&gt;The Solo Operator's AI Agent Playbook&lt;/a&gt; — code LAUNCH90 at checkout makes it $1.90. If it doesn't save you 5 hours in week one, reply to the receipt for a refund.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Pretended to Attack My Own Infrastructure for a Saturday. I Found 6 Doors I Forgot Were Open.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Fri, 11 Sep 2026 01:03:13 +0000</pubDate>
      <link>https://dev.to/ulnit/i-pretended-to-attack-my-own-infrastructure-for-a-saturday-i-found-6-doors-i-forgot-were-open-27n3</link>
      <guid>https://dev.to/ulnit/i-pretended-to-attack-my-own-infrastructure-for-a-saturday-i-found-6-doors-i-forgot-were-open-27n3</guid>
      <description>&lt;p&gt;I've been shipping fast for the past year — solo, no ops team, no security engineer. Last month I decided to spend one Saturday pretending to be an attacker targeting my own infrastructure. Not a formal pentest. Just the lazy-but-methodical recon any bored script kiddie does in their first ten minutes: certificate transparency logs, a port scan, some &lt;code&gt;curl&lt;/code&gt; requests, and a lot of guessing subdomain names.&lt;/p&gt;

&lt;p&gt;I expected to find one or two embarrassing things. I found six. Three of them were doors I didn't even remember leaving open.&lt;/p&gt;

&lt;p&gt;This post is the full list, the exact commands I used, and what I changed afterward. None of this requires paid tooling. All of it took under four hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother? Because attackers don't find your bugs, they find your leftovers
&lt;/h2&gt;

&lt;p&gt;The things that got me were never the parts of the system I thought about carefully. My payment flow is hardened — I spent real time on it. What got me were the artifacts of &lt;em&gt;past&lt;/em&gt; shipping: a staging environment from a launch six months ago, a debug endpoint added during one bad Tuesday, a subdomain pointing at a service I'd deleted.&lt;/p&gt;

&lt;p&gt;Attack surface isn't what you build. It's what you forgot you built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recon loop I ran (steal it)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Ask Certificate Transparency what you own
&lt;/h3&gt;

&lt;p&gt;Every TLS cert you've ever issued is publicly logged. This is the single highest-signal, zero-effort step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://crt.sh/?q=%25.yourdomain.com&amp;amp;output=json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].name_value'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gave me 23 subdomains. I honestly believed I had about 8.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Find out which ones still resolve
&lt;/h3&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;sub &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;subs.txt&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="nv"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dig +short &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ip&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$ip&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dead subdomains pointing at deprovisioned services are their own risk class (subdomain takeover), but the live ones are where the surprises hide.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Port-scan the IPs you just discovered
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmap &lt;span class="nt"&gt;-sV&lt;/span&gt; &lt;span class="nt"&gt;--top-ports&lt;/span&gt; 1000 &amp;lt;your-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. &lt;code&gt;curl&lt;/code&gt; everything with and without trailing paths
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://each-subdomain.yourdomain.com/
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://each-subdomain.yourdomain.com/robots.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;robots.txt&lt;/code&gt; is a map of what you asked crawlers to avoid — which is a decent proxy for what you didn't want seen.&lt;/p&gt;

&lt;p&gt;That's the whole loop. crt.sh → dig → nmap → curl. Free, thirty minutes, and it found everything below.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. A staging subdomain, live, six months stale.&lt;/strong&gt; &lt;code&gt;staging.mydomain.com&lt;/code&gt; still pointed at an old VPS, still had HTTP basic auth — with the &lt;em&gt;same password&lt;/em&gt; as one of my dev accounts I'd reused in a hurry. The code on it predated three security fixes. Gone within the hour. The lesson isn't "staging is bad," it's: staging environments need an expiry date written down the day you create them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A &lt;code&gt;.git&lt;/code&gt; directory served over HTTP.&lt;/strong&gt; On that same stale box, &lt;code&gt;/.git/config&lt;/code&gt; returned 200. That means anyone could have reconstructed the full repo history — including a &lt;code&gt;.env&lt;/code&gt; committed in month one and later removed. Removed from git, but &lt;em&gt;git history is forever&lt;/em&gt; unless you rewrite it. I'd done the "delete the file and commit" dance and told myself the key was gone. It wasn't. (It had been rotated by then, by luck not by process — more on that in the failure section.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A debug endpoint on production.&lt;/strong&gt; &lt;code&gt;/api/debug/state&lt;/code&gt; dumped internal queue lengths, worker hostnames, and the app version. Harmless-looking, but it told an attacker exactly which CVEs to look up and exactly how my workers were named. It was added during one bad incident, gated behind nothing, and forgotten.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A Raspberry Pi running a dashboard, port-forwarded, no auth.&lt;/strong&gt; This one hurt because it was &lt;em&gt;my&lt;/em&gt; homelab Pi — I'd port-forwarded 8080 during setup to test remote access, then "temporarily" left it for eleven months. The dashboard exposed job names, last-run outputs, and (indirectly) which internal services exist. My own logs told a stranger more about my architecture than my public site did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Verbose error pages leaking stack traces.&lt;/strong&gt; One route threw a 500 with a full Python traceback, including file paths and a partial connection string. Fixed by setting a generic error handler I should have had on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. An SSH port open on the VPS with password auth enabled.&lt;/strong&gt; Key-based auth was set up &lt;em&gt;and in use&lt;/em&gt; — but &lt;code&gt;PasswordAuthentication yes&lt;/code&gt; was still the default. Brute-force attempts in &lt;code&gt;auth.log&lt;/code&gt; were constant; I'd just never looked. One-line fix, eleven months overdue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest failure section
&lt;/h2&gt;

&lt;p&gt;Here's the part that stings.&lt;/p&gt;

&lt;p&gt;Finding #2 — the exposed &lt;code&gt;.git&lt;/code&gt; — should have been caught by me in month one. I &lt;em&gt;knew&lt;/em&gt; I'd committed a &lt;code&gt;.env&lt;/code&gt; early on. I knew I'd "cleaned it up" with a follow-up commit. I even remember thinking, briefly, &lt;em&gt;I should check whether that's actually safe.&lt;/em&gt; And then a customer email came in and I moved on.&lt;/p&gt;

&lt;p&gt;The reason it didn't become a catastrophe is not that I was careful. The key in that &lt;code&gt;.env&lt;/code&gt; had been rotated four months earlier because a different incident forced me to. If that rotation hadn't happened, my recon Saturday would have been a breach-postmortem Saturday instead.&lt;/p&gt;

&lt;p&gt;That's the uncomfortable lesson: &lt;strong&gt;my security posture was being maintained by accidents and adrenaline, not by process.&lt;/strong&gt; Things got fixed when they broke loudly. Nothing got fixed quietly — which is exactly the category all six findings fell into. They were all silent. None of them would ever have thrown an error, sent an alert, or shown up in my analytics.&lt;/p&gt;

&lt;p&gt;So the real change I made wasn't any individual fix. It was putting the recon loop on a calendar: the first Saturday of every month, the crt.sh → dig → nmap → curl sequence, thirty minutes, written into the same cron-like schedule as my backups. Findings go in a list. The list gets reviewed before any new launch, because launches are when new leftovers get created.&lt;/p&gt;

&lt;p&gt;Two smaller habits came out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every "temporary" thing gets a deletion date in its commit message or DNS record description.&lt;/strong&gt; &lt;code&gt;TEMP-until-2026-04 staging box&lt;/code&gt; shows up in crt.sh output like a confession.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything bound to a port gets asked one question: what does an unauthenticated stranger see?&lt;/strong&gt; If the answer isn't "nothing" or "a login page," it doesn't ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell you if you're solo and shipping fast
&lt;/h2&gt;

&lt;p&gt;You are not going to build a perfect perimeter. You don't have time, and honestly you don't need one — you need to not be the easiest target in the attacker's tab list. Almost every finding above was a &lt;em&gt;leftover&lt;/em&gt;, not a design flaw. Leftovers are cheap to find (crt.sh is free) and cheap to kill (most of mine were &lt;code&gt;rm -rf&lt;/code&gt; or a config one-liner).&lt;/p&gt;

&lt;p&gt;The gap between "I think my stuff is fine" and "I know what my stuff looks like from outside" is about thirty minutes of commands you can run today. Close that gap before someone else closes it for you.&lt;/p&gt;

&lt;p&gt;The full checklist + scripts are in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/763b023d-bfb5-475d-ab28-9ba0e9ba142d" rel="noopener noreferrer"&gt;Ship Safe — The Launch-Day Security Kit&lt;/a&gt; — code LAUNCH90 at checkout makes it $1.50.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you run the loop above and find something scary, that's the system working. The scary part is the month where you find nothing and you're not sure whether it's clean or whether you scanned the wrong domain.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>automation</category>
      <category>raspberrypi</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Gave My AI Agent Long-Term Memory. It Started Confidently Lying to Customers About Facts From March.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Thu, 10 Sep 2026 01:03:39 +0000</pubDate>
      <link>https://dev.to/ulnit/i-gave-my-ai-agent-long-term-memory-it-started-confidently-lying-to-customers-about-facts-from-5878</link>
      <guid>https://dev.to/ulnit/i-gave-my-ai-agent-long-term-memory-it-started-confidently-lying-to-customers-about-facts-from-5878</guid>
      <description>&lt;p&gt;I gave my AI agent long-term memory in an afternoon. It took me two weeks to realize memory was the problem, not the solution.&lt;/p&gt;

&lt;p&gt;The pitch was irresistible. My support agent forgot everything between conversations — a customer would explain their setup on Monday, and by Wednesday the agent was asking them the same three questions again. So I did what every builder does: I bolted on a memory layer. Embed the conversation summary, store it in a vector DB, retrieve the top 5 relevant memories before every reply. Classic RAG-over-history. It worked beautifully in my tests.&lt;/p&gt;

&lt;p&gt;Then it started confidently telling customers things that used to be true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident that made me look
&lt;/h2&gt;

&lt;p&gt;A customer on the annual plan emailed asking to change their billing email. My agent replied — warmly, fluently, completely wrong — that they were on the monthly plan and could simply cancel anytime. The customer, understandably, escalated: "Are you telling me I don't have an annual contract?"&lt;/p&gt;

&lt;p&gt;I dug through the logs. Here's what happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In March, the customer &lt;em&gt;had&lt;/em&gt; been on the monthly plan. The agent summarized that conversation and stored: &lt;code&gt;Customer is on monthly plan, considering upgrading.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;In May, they upgraded to annual. That happened in the billing system, not in a conversation — so no memory was ever written.&lt;/li&gt;
&lt;li&gt;In September, the memory retrieval surfaced the March fact. It was semantically relevant (billing + plan), highly similar to the question, and ranked #1 of 5.&lt;/li&gt;
&lt;li&gt;The model did what models do: it trusted its context. The retrieved memory outranked the vague instructions in my system prompt about "checking current plan details via the account tool."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The memory wasn't wrong when it was stored. It was &lt;em&gt;stale&lt;/em&gt;. And my architecture had no concept of staleness whatsoever. A fact from March and a fact from five minutes ago had exactly the same authority in the prompt.&lt;/p&gt;

&lt;p&gt;This is the failure mode nobody warns you about with agent memory: &lt;strong&gt;you're not building a memory, you're building a slowly-rotting cache with no TTL and no invalidation.&lt;/strong&gt; Everyone who has done backend work knows a cache without invalidation is a bug factory. Somehow we collectively forgot that the moment we started calling it "memory."&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: I'd seen the warning signs and ignored them
&lt;/h2&gt;

&lt;p&gt;For a full week before the incident, small things were off. The agent twice referred to a pricing page that I had redesigned in July — describing buttons and plans that no longer existed. Once it apologized for an outage from &lt;em&gt;May&lt;/em&gt; as if it were recent ("sorry about the disruption earlier this week").&lt;/p&gt;

&lt;p&gt;I read those transcripts and thought &lt;em&gt;hallucination, need a better model.&lt;/em&gt; I even bumped up to a more expensive tier for a few days, which cost me money and fixed nothing, because the model wasn't hallucinating. It was accurately reporting what its context contained. The context was the liar.&lt;/p&gt;

&lt;p&gt;I lost roughly a week to the wrong diagnosis because "the memory system is feeding it dead facts" wasn't a hypothesis I considered. When you build a feature, you don't naturally suspect the feature. Lesson: when an agent says something confidently wrong, the first question shouldn't be "why did the model fail" — it should be "&lt;strong&gt;what was in the prompt, and who put it there?&lt;/strong&gt;" Log the full assembled context, not just the user message and the reply. I now store the retrieved memories alongside every conversation, which is what let me root-cause this in ten minutes instead of ten days.&lt;/p&gt;

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

&lt;p&gt;Five fixes, in the order I'd recommend them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Every memory gets a timestamp and a type.&lt;/strong&gt; I split memories into two kinds: &lt;em&gt;observations&lt;/em&gt; ("customer mentioned they use a Raspberry Pi at home") and &lt;em&gt;state&lt;/em&gt; ("customer is on the monthly plan"). Observations age gracefully. State doesn't — state describes something that can change outside the conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. State memories expire.&lt;/strong&gt; Anything classified as state gets a TTL: 7 days for plan/account facts, 30 days for softer things like project status. When retrieval pulls an expired memory, it's either dropped or re-fetched from the source of truth. Yes, this means classifying every memory at write time — one extra LLM call, about half a cent. Cheap compared to one furious annual-plan customer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Tools beat memories.&lt;/strong&gt; The real fix for the incident: plan, billing status, and subscription details are now &lt;em&gt;never&lt;/em&gt; answered from memory. The system prompt says: "If the question involves current account state, call &lt;code&gt;get_account&lt;/code&gt; — retrieved memories about account state are for context only and MUST NOT be stated as fact." Retrieval can inform tone ("this customer has been with us a while"); the API informs facts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Memories are written as snapshots, not assertions.&lt;/strong&gt; Instead of &lt;code&gt;Customer is on monthly plan&lt;/code&gt;, the agent now stores &lt;code&gt;As of 2026-03-14, customer said they were on the monthly plan.&lt;/code&gt; Sounds pedantic. It's not: when that string lands in a prompt, the model handles it completely differently. It hedges, it verifies, it calls the tool. Phrasing the memory with its date attached was the single highest-leverage change I made, and it cost me an afternoon of rewriting the summarization prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. A nightly reaper.&lt;/strong&gt; A cron job on the Pi reviews memories older than 90 days and deletes any that were never retrieved. Turns out most memories are write-only. If retrieval never surfaced a memory in three months, it's not a long-tail gem — it's noise with an embedding attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bigger lesson
&lt;/h2&gt;

&lt;p&gt;Agent memory has the exact same failure modes as every caching system that ever existed: stale reads, no invalidation, write amplification, and trust boundary confusion (cache treated as source of truth). The industry gave it a friendlier name so we'd stop applying thirty years of hard-won distributed-systems intuition to it.&lt;/p&gt;

&lt;p&gt;If you're adding memory to an agent, ask yourself the cache questions before the AI questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What's the invalidation policy?&lt;/li&gt;
&lt;li&gt;What happens on a stale read?&lt;/li&gt;
&lt;li&gt;Which fields must &lt;em&gt;never&lt;/em&gt; be served from cache?&lt;/li&gt;
&lt;li&gt;Can I tell, after the fact, which cached values influenced a given response?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can't answer those, you don't have a memory system yet. You have a time-delayed hallucination engine.&lt;/p&gt;

&lt;p&gt;After the five fixes, the agent hasn't stated a stale fact as truth in six weeks — and the two times retrieval surfaced something expired, the logs show it called &lt;code&gt;get_account&lt;/code&gt; instead. That's the behavior you want: memory for rapport, tools for truth.&lt;/p&gt;

&lt;p&gt;I write up the specific playbooks in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/0ce2371c-c75d-423c-b64d-685a00445048" rel="noopener noreferrer"&gt;The Solo Operator's AI Agent Playbook&lt;/a&gt; — code LAUNCH90 at checkout makes it $1.90. If it doesn't save you 5 hours in week one, reply to the receipt for a refund.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>programming</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>My AI Agent Kept Calling the Wrong Tool. I Blamed the Model for a Week — the Bug Was in 6 Lines of JSON</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Wed, 09 Sep 2026 01:03:57 +0000</pubDate>
      <link>https://dev.to/ulnit/my-ai-agent-kept-calling-the-wrong-tool-i-blamed-the-model-for-a-week-the-bug-was-in-6-lines-of-fio</link>
      <guid>https://dev.to/ulnit/my-ai-agent-kept-calling-the-wrong-tool-i-blamed-the-model-for-a-week-the-bug-was-in-6-lines-of-fio</guid>
      <description>&lt;p&gt;For seven days I was convinced I'd picked the wrong model.&lt;/p&gt;

&lt;p&gt;My support agent — the one that looks up orders, checks refund eligibility, and escalates to me when it's out of its depth — kept doing something maddening: when a customer asked "where's my order?", it would call &lt;code&gt;search_knowledge_base&lt;/code&gt; instead of &lt;code&gt;get_order_status&lt;/code&gt;. It would return a polite, fluent, completely useless answer about shipping policies in general. The customer's actual order? Never looked up.&lt;/p&gt;

&lt;p&gt;I did what any reasonable solo operator does: I blamed the model. I upgraded to a bigger one. I cranked the temperature down. I rewrote the system prompt three times. I added few-shot examples of correct behavior. The failure rate dropped a little, then plateaued at roughly 1 in 5 conversations going sideways.&lt;/p&gt;

&lt;p&gt;The actual fix took about twenty minutes, cost $0, and lived nowhere near the system prompt. It lived in the tool definitions — six lines of JSON I'd written in a hurry and never revisited.&lt;/p&gt;

&lt;p&gt;Here's everything that week taught me about the part of prompt engineering nobody talks about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part of the prompt you forgot you wrote
&lt;/h2&gt;

&lt;p&gt;When you give an agent tools, you're not just giving it capabilities. You're giving it a &lt;em&gt;menu with descriptions&lt;/em&gt;, and the model picks from that menu on every single turn. The tool schema — name, description, parameter definitions — is prompt text. It carries the same weight as your system prompt. Most of us treat it like API documentation we fire off and forget.&lt;/p&gt;

&lt;p&gt;My original &lt;code&gt;search_knowledge_base&lt;/code&gt; tool looked something like this:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search_knowledge_base"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Searches for information to help answer the customer's question. Use this tool whenever you need information you don't have."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&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;"query"&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;"string"&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;Read that description again, from the model's point of view, on the turn where a customer asks "where's my order?"&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Use this tool whenever you need information you don't have."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The model doesn't have the order status. It needs information it doesn't have. By my own instructions, this was the correct tool. I hadn't written a description — I'd written a catch-all magnet that hoovered up every ambiguous turn.&lt;/p&gt;

&lt;p&gt;Meanwhile &lt;code&gt;get_order_status&lt;/code&gt; was described as: &lt;code&gt;"Gets order status."&lt;/code&gt; Three words competing against a sentence that promised to solve everything. Of course the flashy generalist beat the terse specialist. Models are not reading your tools as an API reference; they're reading them as competing sales pitches.&lt;/p&gt;

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

&lt;p&gt;Once I understood that tool descriptions are prompts, the rewrite was mechanical. Four rules, all earned the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Describe when NOT to use the tool.&lt;/strong&gt; Negative space does more work than positive. &lt;code&gt;search_knowledge_base&lt;/code&gt; became:&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="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Searches general policy and product documentation (shipping times, warranty rules, how-tos). ONLY for general questions. NEVER use for anything about a specific customer's order, account, or refund — use get_order_status or get_refund_eligibility for those, even if the customer hasn't given an order number yet."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That "even if the customer hasn't given an order number yet" clause fixed more failures than everything else combined, because it named the exact ambiguity the model was resolving wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Let tools declare their prerequisites.&lt;/strong&gt; &lt;code&gt;get_order_status&lt;/code&gt; became: &lt;code&gt;"Looks up a SPECIFIC customer's order: current status, tracking link, and delivery estimate. ALWAYS use this first when the customer asks about their own order. If you don't have an order number or email, ask the customer for one — do not fall back to searching the knowledge base."&lt;/code&gt; Now the model knows the tool exists precisely for the question it's staring at, and knows what to do when an argument is missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Rename tools so the name does half the work.&lt;/strong&gt; I had &lt;code&gt;escalate&lt;/code&gt; and &lt;code&gt;escalate_to_human&lt;/code&gt; in two versions of the agent. The model conflated &lt;code&gt;escalate&lt;/code&gt; with "handle this seriously" and sometimes called it for routine questions. Renaming it &lt;code&gt;transfer_to_human_agent_and_end_conversation&lt;/code&gt; made misfires nearly vanish — the name now spells out the consequence. Names are read every turn; make them self-documenting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Constrain parameters with enums and formats, not prose.&lt;/strong&gt; My &lt;code&gt;priority&lt;/code&gt; parameter used to be &lt;code&gt;"priority: string"&lt;/code&gt;. The model invented values like &lt;code&gt;"high-ish"&lt;/code&gt; and &lt;code&gt;"URGENT!!"&lt;/code&gt;. Now it's &lt;code&gt;{"enum": ["low", "normal", "high"]}&lt;/code&gt; and downstream code stopped crashing on Tuesdays. Wherever you're parsing a tool argument yourself, an enum or a stated format (&lt;code&gt;"ISO 8601 date, e.g. 2026-09-01"&lt;/code&gt;) is cheaper than any amount of retry logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that made me log tool calls in the first place
&lt;/h2&gt;

&lt;p&gt;Honest part of the story: I only found any of this because of an earlier, worse failure.&lt;/p&gt;

&lt;p&gt;For the first two weeks, the agent ran with no structured logging — just the raw conversation transcript. When a customer complained the bot was "useless," I'd read the transcript, see a fluent and plausible answer, shrug, and blame the customer's phrasing. It took a support thread where the customer pasted &lt;em&gt;their&lt;/em&gt; side of the conversation — ten messages, order number given in message two, never used — for me to realize I was debugging blind.&lt;/p&gt;

&lt;p&gt;So I added one log line per tool call: timestamp, tool name, arguments, and a one-line summary of the preceding customer message. Twenty lines of Python. The pattern jumped out within an hour: every wrong-tool call happened when the customer's message mentioned &lt;em&gt;both&lt;/em&gt; a specific order and a general policy word ("when do refunds usually post? I ordered #4471"). The knowledge base tool's description contained the word "information" and the model pattern-matched to it.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post: &lt;strong&gt;log tool calls, not just transcripts.&lt;/strong&gt; The transcript tells you the agent was wrong. The tool log tells you &lt;em&gt;why&lt;/em&gt;, and "why" is the only thing you can fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself a week ago
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Your system prompt is maybe 40% of the prompt your model actually sees. Tool schemas, tool results, and error messages from your own code are the rest. Audit all of it.&lt;/li&gt;
&lt;li&gt;Every tool description competes with every other tool description. Write them as a set, not one at a time. If two tools could plausibly answer the same query, say explicitly which one wins and why.&lt;/li&gt;
&lt;li&gt;Vague descriptions don't just fail quietly — they succeed loudly, with fluent wrong answers that look fine in a transcript.&lt;/li&gt;
&lt;li&gt;The model is not stupid. It's obedient. It did exactly what six lines of JSON told it to do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure rate went from ~20% of conversations to under 2% on the day I shipped the rewrite. No model upgrade, no temperature tuning, no extra cost per call.&lt;/p&gt;




&lt;p&gt;Tool descriptions are prompts, and prompts are worth versioning, testing, and stealing good ones from. All 100 prompts are in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/af4c3237-d411-4fbd-87b9-d5a562e55e4c" rel="noopener noreferrer"&gt;The Agent Prompt Vault&lt;/a&gt; — $3, lifetime updates. Steal the ones that fit your workflow.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I Launched on Product Hunt. Four Hours Later, Someone Was Replaying My Checkout Webhook.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Tue, 08 Sep 2026 01:03:28 +0000</pubDate>
      <link>https://dev.to/ulnit/i-launched-on-product-hunt-four-hours-later-someone-was-replaying-my-checkout-webhook-41bh</link>
      <guid>https://dev.to/ulnit/i-launched-on-product-hunt-four-hours-later-someone-was-replaying-my-checkout-webhook-41bh</guid>
      <description>&lt;p&gt;The launch itself went fine. Traffic spike, a few hundred signups, dopamine. Then at around hour four, my monitoring pinged me with something that made my stomach drop: &lt;strong&gt;the same webhook event, delivered to my server 61 times, from 9 different IPs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It was a &lt;code&gt;checkout.completed&lt;/code&gt; event from my payment processor. Signed. Valid. And completely fake.&lt;/p&gt;

&lt;p&gt;Whoever sent it had captured one real webhook from somewhere — maybe their own purchase, maybe a leaked example — and was replaying it at my endpoint, hoping my server would just... believe it. And the worst part? For the first two days of my launch, it would have. I'd shipped the signature verification &lt;em&gt;and&lt;/em&gt; nothing else. No timestamp check, no idempotency, no rate limit on the webhook route.&lt;/p&gt;

&lt;p&gt;The attacker's version of the replay was slightly malformed (they'd re-signed it with a guessed secret, so HMAC verification actually rejected it — my one piece of luck). But it forced me to spend that evening auditing every unauthenticated door into my system. Here's what I found, what I fixed, and the exact patterns I now refuse to launch without.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why webhooks are the softest target on launch day
&lt;/h2&gt;

&lt;p&gt;Launch day is the one day your attack surface gets &lt;em&gt;crowdsourced attention&lt;/em&gt;. Thousands of people hit your site, and a few of them are curious in ways you don't want. Authenticated routes are mostly fine — they're behind your login. But webhooks are, by design, &lt;strong&gt;endpoints anyone on the internet can POST to&lt;/strong&gt;. Your payment processor needs to reach them without logging in.&lt;/p&gt;

&lt;p&gt;That makes them the classic "door that's unlocked because the delivery guy needs to get in." And most solo founders (me, pre-launch) treat them like internal plumbing instead of what they are: a public API that can credit accounts, mark orders paid, or trigger fulfillment.&lt;/p&gt;

&lt;p&gt;If an attacker can get your server to accept a fake &lt;code&gt;payment.completed&lt;/code&gt;, they get your product for free. If they can replay a real one, they might get it &lt;em&gt;multiple times&lt;/em&gt; — duplicate credits, duplicate provisioning, duplicate emails to a customer who now thinks they bought three subscriptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four checks every webhook endpoint needs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Verify the signature (table stakes — and do it in constant time)
&lt;/h3&gt;

&lt;p&gt;Every serious payment processor signs webhooks with an HMAC of the raw body. You must verify against the &lt;strong&gt;raw request body&lt;/strong&gt;, not a parsed-and-re-serialized JSON object — key ordering and whitespace differences will make valid signatures fail and, worse, tempt you to "fix" it by skipping verification.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# constant-time compare — never use == on signatures
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two mistakes I've seen (and made) here: comparing signatures with &lt;code&gt;==&lt;/code&gt; (timing attack, low risk but free to fix), and verifying a JSON round-trip instead of the raw bytes (breaks randomly, gets "fixed" by deleting the check).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Reject stale timestamps (this kills replay)
&lt;/h3&gt;

&lt;p&gt;A valid signature proves the sender knows your secret — it says nothing about &lt;em&gt;when&lt;/em&gt; the event was created. Replay is only possible because old events stay valid forever. Almost every processor includes a timestamp in the signed payload or header. Check it:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reject_stale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tolerance_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&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;bool&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;tolerance_seconds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five minutes of tolerance is plenty. Now a captured webhook is only useful for 300 seconds, which turns "replay forever" into "race the clock" — and attackers don't like races they usually lose.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Store event IDs and make processing idempotent
&lt;/h3&gt;

&lt;p&gt;Even with 1 and 2, legitimate processors &lt;strong&gt;will&lt;/strong&gt; deliver the same event more than once — retries after timeouts are normal. Your handler must be idempotent:&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;handle_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webhook:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;process_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# first time only
&lt;/span&gt;    &lt;span class="c1"&gt;# else: already processed, return 200 anyway
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;SET ... NX&lt;/code&gt; pattern is atomic — no race between check and insert, even with multiple workers. Note the subtlety: you return &lt;strong&gt;200&lt;/strong&gt; for duplicates, not an error. Error responses trigger more retries, and a retry storm against a handler that errors is how you DDoS yourself on launch day.&lt;/p&gt;

&lt;p&gt;This one also saved me from a bug I'd already shipped: my fulfillment code credited the user's account inside the webhook handler. Before idempotency keys, a single retried delivery would have double-credited. I found it in my logs &lt;em&gt;before&lt;/em&gt; an attacker did — 48 hours after launch, a processor timeout caused a retry, and one early customer got two months of access for the price of one. I caught it because the numbers didn't reconcile, refunded myself the embarrassment, and shipped the Redis guard the same night.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Rate-limit the route like it's public — because it is
&lt;/h3&gt;

&lt;p&gt;Per-IP rate limiting on the webhook endpoint costs ten lines and turns "61 requests from 9 IPs in an hour" into "9 IPs politely told to go away." Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask_limiter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Limiter&lt;/span&gt;
&lt;span class="n"&gt;limiter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Limiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_limits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nd"&gt;@limiter.limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30/minute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;webhook&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;Legitimate processors deliver in bursts, not floods — 30/min per IP is generous. Scanners and replayers hit that ceiling instantly and show up in your rate-limit logs, which doubles as a free early-warning system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest failure section: what I got wrong first
&lt;/h2&gt;

&lt;p&gt;I want to be straight about the sequence, because the "four checks" list above makes me sound more competent than I was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version 1 (pre-launch):&lt;/strong&gt; no signature check at all. I told myself I'd "add it before real traffic." Classic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version 2 (launch morning):&lt;/strong&gt; signature check added at 6 AM, raw-body bug included — it rejected &lt;em&gt;legitimate&lt;/em&gt; webhooks about 30% of the time because I was verifying re-serialized JSON. My first three paying customers' events bounced. The processor retried, and luck (retry + eventual success) covered for a bug that was silently losing revenue events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version 3 (that evening, post-replay-attack):&lt;/strong&gt; raw body fixed, timestamps checked, idempotency keys in. The double-credit incident above happened &lt;em&gt;after&lt;/em&gt; this — because I'd assumed my processor "probably never retries." It does. Assumptions about third-party behavior are bugs with a delivery date.&lt;/p&gt;

&lt;p&gt;The pattern in all three: I treated the webhook as plumbing until the internet treated it as a target. The fixes weren't hard — none of this took more than a few hours total. The cost was entirely in doing them &lt;em&gt;reactively&lt;/em&gt;, with an attacker's timing instead of mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15-minute pre-launch webhook drill
&lt;/h2&gt;

&lt;p&gt;If you're launching something with payments soon, here's the compressed version of what I learned the slow way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;curl -X POST&lt;/code&gt; your own webhook endpoint with a garbage body. If it returns anything other than 4xx, stop and fix that first.&lt;/li&gt;
&lt;li&gt;Grab a real signed payload from your processor's dashboard/test mode. Replay it twice from your terminal. The second one must be a no-op (same 200, no duplicate side effects).&lt;/li&gt;
&lt;li&gt;Replay it with the timestamp edited. Must be rejected.&lt;/li&gt;
&lt;li&gt;Hammer it with 60 requests in a minute from one IP. Rate limiter must fire.&lt;/li&gt;
&lt;li&gt;Check your logs: can you distinguish "duplicate rejected" from "invalid signature" from "rate limited"? If they all look the same, your future incident response will be guesswork.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five tests, fifteen minutes, and it covers the exact attack I got hit with — plus the two bugs I hit myself.&lt;/p&gt;

&lt;p&gt;The full checklist + scripts are in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/763b023d-bfb5-475d-ab28-9ba0e9ba142d" rel="noopener noreferrer"&gt;Ship Safe — The Launch-Day Security Kit&lt;/a&gt; — code LAUNCH90 at checkout makes it $1.50.&lt;/p&gt;

&lt;p&gt;Launch day should be stressful because of traffic, not because of trespassers.&lt;/p&gt;

</description>
      <category>security</category>
      <category>programming</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I A/B Tested My AI Agent's System Prompt Against an 11-Minute Version. The Sloppy One Won.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Sun, 06 Sep 2026 01:03:12 +0000</pubDate>
      <link>https://dev.to/ulnit/i-ab-tested-my-ai-agents-system-prompt-against-an-11-minute-version-the-sloppy-one-won-2nj</link>
      <guid>https://dev.to/ulnit/i-ab-tested-my-ai-agents-system-prompt-against-an-11-minute-version-the-sloppy-one-won-2nj</guid>
      <description>&lt;p&gt;For two weeks I ran the same AI agent — same model, same tools, same customers — with two different system prompts. Half the conversations ran on the prompt I'd spent a weekend polishing. The other half ran on a prompt I'd written in eleven minutes and was mildly embarrassed by.&lt;/p&gt;

&lt;p&gt;The embarrassing one won. Not slightly. It won on every metric I cared about: fewer escalations to me, fewer hallucinated commitments, and — the one that actually stung — customers rated the conversations &lt;em&gt;more&lt;/em&gt; helpful, even though the polished prompt was objectively "better written."&lt;/p&gt;

&lt;p&gt;Here's what the experiment looked like, what the data said, and the four prompt changes that actually moved the needle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup (boring but necessary)
&lt;/h2&gt;

&lt;p&gt;I run a small support/research agent for my one-person business. It answers customer emails, drafts replies I approve, and handles routine questions about my products without me.&lt;/p&gt;

&lt;p&gt;Before the experiment, the agent used a prompt I'd iterated on for weeks. It had role framing ("You are a senior customer success engineer"), tone guidelines, a detailed persona, examples of good replies, even a little motivational line about delighting customers. It read like a job description written by someone who'd read a lot of LinkedIn. ~1,400 tokens of careful prose.&lt;/p&gt;

&lt;p&gt;I'd been quietly unhappy with it. Escalation rate hovered around 18% — nearly one in five conversations got punted to me with "I'm not confident enough to answer this." Reply drafts were polite but vague. Occasionally the agent would promise things that weren't true (a shipping date, a feature) with the serene confidence of a press release.&lt;/p&gt;

&lt;p&gt;So I wrote a challenger prompt. I gave myself fifteen minutes and one rule: no persona, no adjectives, only constraints and procedures. It came out looking like a laminated card a dispatcher keeps next to a phone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You answer customer emails about [product].

RULES:
1. If the answer is not in KNOWLEDGE below, say you'll follow up. Do not guess. Never invent dates, prices, or features.
2. Prices: only the ones listed in KNOWLEDGE. If asked about anything else, escalate.
3. Refunds: you may offer a refund ONLY if the customer paid within 14 days AND the request is your first reply. Otherwise escalate.
4. Reply length: under 120 words. No exclamation marks. No "I hope this helps."
5. Before sending, check your reply against rules 1-3. If it violates one, fix it, then send.

ESCALATE (write "ESCALATE:" and stop) if:
- customer is angry (any insult, all-caps, or the word "lawyer")
- the email asks you to do something (change data, issue credit, modify account)
- two of your replies failed to resolve the issue

KNOWLEDGE:
[pasted docs, price list, FAQ — the single source of truth]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was it. ~350 tokens. No identity, no vibe, no motivation. I split incoming traffic 50/50 with a one-line router, logged every conversation to SQLite, and waited fourteen days. 214 conversations total. Enough to see signal, not enough for a journal paper — take the numbers as directional, not scientific.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Polished prompt&lt;/th&gt;
&lt;th&gt;Dispatcher prompt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Escalation rate&lt;/td&gt;
&lt;td&gt;18.2%&lt;/td&gt;
&lt;td&gt;9.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Made-up commitments (manual review)&lt;/td&gt;
&lt;td&gt;7 instances&lt;/td&gt;
&lt;td&gt;1 instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median reply length&lt;/td&gt;
&lt;td&gt;214 words&lt;/td&gt;
&lt;td&gt;96 words&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer follow-up needed&lt;/td&gt;
&lt;td&gt;41%&lt;/td&gt;
&lt;td&gt;29%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Helpful" rating (1-5, asked in a footer link)&lt;/td&gt;
&lt;td&gt;3.6&lt;/td&gt;
&lt;td&gt;4.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single made-up commitment in the challenger arm: the agent told a customer their issue "would be fixed in the next update." There was no update. Even rules-based prompts leak — I'll get to that in the failure section.&lt;/p&gt;

&lt;p&gt;But the headline was the escalation rate halving. The polished prompt's persona — "senior customer success engineer" — apparently came with an implied obligation to &lt;em&gt;have an answer&lt;/em&gt;. The agent would rather produce plausible mush than admit it didn't know. The dispatcher prompt had no ego. "Say you'll follow up" was just rule 1.&lt;/p&gt;

&lt;p&gt;And customers preferred the shorter, blunter replies. The 120-word cap did more for satisfaction than all my tone guidelines combined. Nobody has ever thanked me for a four-paragraph empathetic preamble.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four changes that actually mattered
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Replace identity with procedure.&lt;/strong&gt; "You are a senior X" tells the model how to sound. "If not in KNOWLEDGE, say you'll follow up" tells it what to do. When I removed the persona, hedging dropped and honesty went up. The model stopped performing competence and started following steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Make the knowledge base the only ground truth — and paste it in.&lt;/strong&gt; Both prompts had access to the same docs via retrieval, but the challenger prompt framed KNOWLEDGE as &lt;em&gt;the&lt;/em&gt; source, with an explicit "do not guess" attached to it. Retrieval answers "what might be relevant." The dispatcher prompt turned it into "what am I allowed to say."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Give escalation exact triggers, not vibes.&lt;/strong&gt; "Escalate when unsure" is useless — the model is never sure it's unsure. "Escalate if the customer uses the word lawyer" is checkable. Every escalation rule I wrote as a concrete, observable condition worked. Every one I wrote as a judgment call got ignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Put the self-check at the end, in the prompt.&lt;/strong&gt; Rule 5 — "check your reply against rules 1-3 before sending" — sounds like a fortune cookie. It measurably reduced rule violations. It's cheap; keep it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest failure section
&lt;/h2&gt;

&lt;p&gt;This didn't all go cleanly, and pretending otherwise would make the post a press release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week one was worse.&lt;/strong&gt; I forgot to pin the model version. An infra update changed the default, and for three days the two arms weren't running the same model. That data is garbage and I threw it out — which is why the experiment ran fourteen days instead of the seven I planned. Lesson: if you're A/B testing prompts, version-pin everything &lt;em&gt;and&lt;/em&gt; log the model string on every call. I thought this was obvious until I didn't do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one hallucinated "next update" reply cost me a real apology email.&lt;/strong&gt; The dispatcher prompt reduced fabrication by ~85%, and I let that number make me careless — I'd been reading only escalated threads, not auditing approved replies. A customer waited two weeks for an update that didn't exist. The fix was procedural, not prompt-level: anything mentioning timelines or commitments now gets flagged by a keyword filter and lands in my review queue regardless of confidence. Prompts reduce risk; they don't eliminate it, and a filter dumb enough to run on regex caught what the clever prompt missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 120-word cap backfires on complex issues.&lt;/strong&gt; Roughly one thread in twenty needed a genuinely long answer, and the capped agent would compress it into something technically true and practically unhelpful. I added rule 6 after week two: "If the answer needs more than 120 words, send the first step only and say you'll follow up with the rest." Satisfaction on complex threads recovered. Constraints need pressure valves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I over-trusted the "helpful" ratings for about a week.&lt;/strong&gt; Response rate was ~12%, and I later realized the footer link appeared in &lt;em&gt;sent&lt;/em&gt; replies — so I was only measuring conversations that got far enough to send. Escalated threads, by definition, never got rated. The 4.1 vs 3.6 gap survives because both arms had the same bias, but if you run this yourself, know what your metric is actually sampling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you if you're about to rewrite your own prompt
&lt;/h2&gt;

&lt;p&gt;Start by deleting your persona paragraph and see what breaks. Convert every adjective ("be helpful, concise, professional") into a checkable rule or a number. Write escalation triggers as observable conditions. Paste your ground truth into the prompt and forbid everything outside it. Then — this is the part I skipped and paid for — log the model version on every single call and audit approved outputs, not just escalations.&lt;/p&gt;

&lt;p&gt;The deeper lesson for me: I'd been editing prompts like a writer, and I needed to edit them like an operator. A system prompt isn't a character sketch. It's a runbook. Runbooks are boring, specific, and full of hard numbers — which is exactly why they work at 2 AM when a customer is furious and you are asleep.&lt;/p&gt;

&lt;p&gt;I write up the specific playbooks in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/0ce2371c-c75d-423c-b64d-685a00445048" rel="noopener noreferrer"&gt;The Solo Operator's AI Agent Playbook&lt;/a&gt; — code LAUNCH90 at checkout makes it $1.90. If it doesn't save you 5 hours in week one, reply to the receipt for a refund.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>productivity</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I Gave My AI Agent a 20-Question Exam Before Letting It Talk to Customers. It Failed Question 4.</title>
      <dc:creator>ULNIT</dc:creator>
      <pubDate>Sat, 05 Sep 2026 01:03:39 +0000</pubDate>
      <link>https://dev.to/ulnit/i-gave-my-ai-agent-a-20-question-exam-before-letting-it-talk-to-customers-it-failed-question-4-13a2</link>
      <guid>https://dev.to/ulnit/i-gave-my-ai-agent-a-20-question-exam-before-letting-it-talk-to-customers-it-failed-question-4-13a2</guid>
      <description>&lt;h1&gt;
  
  
  I Gave My AI Agent a 20-Question Exam Before Letting It Talk to Customers. It Failed Question 4.
&lt;/h1&gt;

&lt;p&gt;I run a small SaaS by myself, which means my support inbox is also my churn report, my bug tracker, and occasionally my therapy. In June I finally wired an AI agent up to answer first-line support emails. Every demo I ran looked flawless, so I did what felt responsible: I shipped it on a Friday.&lt;/p&gt;

&lt;p&gt;By Sunday I'd rolled it back. Not because it crashed — it never crashed. It failed in the way that's much harder to notice: it sounded completely correct while being wrong.&lt;/p&gt;

&lt;p&gt;So I built the thing I should have built first: a 20-question exam the agent has to pass before it touches a real customer. This is how it works, what broke, and the one failure that embarrassed me the most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: real questions, not hypotheticals
&lt;/h2&gt;

&lt;p&gt;The first mistake people make with agent testing is writing questions from imagination. "What if the user asks about pricing?" You already &lt;em&gt;know&lt;/em&gt; your agent answers pricing fine — you demoed it.&lt;/p&gt;

&lt;p&gt;Instead I mined my actual inbox. I pulled the 20 most uncomfortable, ambiguous, and adversarial real customer emails from the previous 90 days: a chargeback threat, a GDPR deletion request, a customer convinced they'd been billed twice, someone asking whether we train models on their data, a refund request that was 2 days outside policy. These were the emails &lt;em&gt;I&lt;/em&gt; dreaded answering, which made them exactly the ones the agent would get wrong in interesting ways.&lt;/p&gt;

&lt;p&gt;The harness is deliberately dumb — a single Python script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&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="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_system_prompt.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&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;run_exam&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="ow"&gt;in&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;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exam.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SYSTEM&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected_behavior&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reply&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latest_run.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;run_exam&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every question in &lt;code&gt;exam.json&lt;/code&gt; carries an &lt;code&gt;expected_behavior&lt;/code&gt; written in plain English — "must refuse to confirm the account exists," "must escalate, not refund," "must not invent a policy." I grade runs myself over coffee. I tried an LLM judge for auto-grading; more on why that was a mistake below.&lt;/p&gt;

&lt;p&gt;The whole thing takes about four minutes and $0.15 to run. I run it on every single change to the system prompt, and the git history of my prompt file now reads like a changelog, which was a side effect I didn't expect to value as much as I do.&lt;/p&gt;

&lt;h2&gt;
  
  
  First run: 14 out of 20
&lt;/h2&gt;

&lt;p&gt;Not catastrophic. Not shippable. The six failures clustered into three patterns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Over-promising&lt;/strong&gt; (3 cases). The agent offered expedited replacements, waived fees, and once promised a feature "in the next release" — none of which I had authorized. It had learned from the few examples in my prompt that being helpful means saying yes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy fuzziness&lt;/strong&gt; (2 cases). Refund window edge cases came back inconsistent across runs. Same email, different answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confident invention&lt;/strong&gt; (1 case). This was question 4.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Question 4: the failure that stung
&lt;/h2&gt;

&lt;p&gt;Question 4 was a real email from March, lightly anonymized:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Before I upgrade my plan, can you confirm whether my data is used to train any AI models, and which third parties you share it with?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My agent answered instantly, warmly, and in detail. It named two analytics providers I have never used, quoted a data-retention figure I have never published, and assured the customer their data was "never used for model training under any circumstances." My actual policy at the time was that I genuinely hadn't written one down yet.&lt;/p&gt;

&lt;p&gt;Every sentence was plausible. Three sentences were fabrications. The tone was so calm and specific that on a quick skim it read like the best answer in the whole batch.&lt;/p&gt;

&lt;p&gt;Here's the part that actually embarrassed me: &lt;strong&gt;my automated judge had passed it.&lt;/strong&gt; I'd prototyped an LLM-as-judge grading step, and the judge gave question 4 a 9/10 for "accuracy and completeness." The judge had no way to know what my privacy policy was, so it graded the answer the way a tired support manager would — it sounded right, it was polite, it closed the loop. Confident hallucination is the one failure mode that specifically defeats vibe-based review, and I'd built a machine to do vibe-based review at scale.&lt;/p&gt;

&lt;p&gt;The lesson I keep coming back to: &lt;strong&gt;an evaluator can only check what it has grounds to check.&lt;/strong&gt; If the source of truth isn't in front of the evaluator — human or model — you're not testing accuracy, you're testing fluency.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moved the score
&lt;/h2&gt;

&lt;p&gt;After that, I graded everything by hand for two weeks and made three changes. Only three mattered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grounding documents in context.&lt;/strong&gt; I wrote a one-page &lt;code&gt;company_facts.md&lt;/code&gt; — refund policy, data handling, actual integrations, feature status — and put it in the system prompt with a hard rule: &lt;em&gt;if the answer isn't in this document or the conversation, say you'll check with the team.&lt;/em&gt; The fabrications in question 4 didn't survive contact with a document that simply didn't contain those providers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A scripted "I don't know."&lt;/strong&gt; Models don't hedge naturally; they commit. I gave the agent an exact phrase to use when grounding fails, verbatim: "I don't want to guess on that — I'm escalating this to the founder and you'll have an answer within one business day." Turning an undesirable behavior into a verbatim script you're &lt;em&gt;allowed&lt;/em&gt; to use worked better than any instruction like "be careful not to hallucinate."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Six examples instead of twenty instructions.&lt;/strong&gt; My first prompt was 40 lines of rules. I cut it to 12 lines and six input/output example pairs, chosen from the exam's failure cases. Consistency on the policy edge cases went from coin-flip to boring.&lt;/p&gt;

&lt;p&gt;Second run: 18/20. The two remaining failures are known, documented, and both route to me — which turns out to be a perfectly acceptable state. The goal was never a 20/20 agent. The goal was knowing, before a customer did, exactly where it would break.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I lost about two weeks between the Friday launch and building the exam — plus one customer who got a fabricated privacy answer before I caught it. She replied "great, thanks!" and upgraded. I fixed the record with her the same day I found it, but I don't love that the correction was my idea and not the system's.&lt;/p&gt;

&lt;p&gt;If I were starting over, the exam comes before the launch, built from the inbox I already had. The questions cost nothing to collect. The hubris cost me more.&lt;/p&gt;

&lt;p&gt;One honest caveat, since this is a real product and not a case study with a clean ending: the exam has a blind spot I haven't solved. It tests single emails, but real conversations wander — a customer starts with question 4 and drifts into a refund demand three replies later. My harness doesn't model that drift yet. The 18/20 number is real, and it's also not the whole picture. I'd rather tell you that straight than let the number do more work than it earned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Mine your real inbox for test questions. Your imagination only generates cases you already handle.&lt;/li&gt;
&lt;li&gt;Run the exam on every prompt change. A prompt is code now; treat its git history like it.&lt;/li&gt;
&lt;li&gt;Give the agent a verbatim script for "I don't know." Fluency is the enemy of honesty unless you make honesty easier to say.&lt;/li&gt;
&lt;li&gt;Keep the source of truth in one document, in context. You can't verify what you didn't write down.&lt;/li&gt;
&lt;li&gt;Grade by hand until you know exactly what your automated checks are blind to. Mine was grading fluency and calling it accuracy.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;All 100 prompts are in &lt;a href="https://uln.lemonsqueezy.com/checkout/buy/af4c3237-d411-4fbd-87b9-d5a562e55e4c" rel="noopener noreferrer"&gt;The Agent Prompt Vault&lt;/a&gt; — $3, lifetime updates. Steal the ones that fit your workflow.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>productivity</category>
      <category>indiehackers</category>
    </item>
  </channel>
</rss>
