<?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: Łukasz Blania</title>
    <description>The latest articles on DEV Community by Łukasz Blania (@lukasz_blania_4b7d226fa2a).</description>
    <link>https://dev.to/lukasz_blania_4b7d226fa2a</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3435601%2F4a053415-2793-4d83-8e8c-da08f5bcdbad.png</url>
      <title>DEV Community: Łukasz Blania</title>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lukasz_blania_4b7d226fa2a"/>
    <language>en</language>
    <item>
      <title>Stop AI articles from hallucinating: retrieve, then write</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Tue, 02 Jun 2026 17:55:16 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/stop-ai-articles-from-hallucinating-retrieve-then-write-1i9g</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/stop-ai-articles-from-hallucinating-retrieve-then-write-1i9g</guid>
      <description>&lt;p&gt;Last year a single fabricated statistic in a published article cost me a client's trust. The model wrote "73% of small businesses report X" with total confidence. The number did not exist. Nobody had ever measured it. The client found out before I did.&lt;/p&gt;

&lt;p&gt;That one sentence taught me more than any prompt engineering thread ever did. I have since shipped 63,000 articles through a production pipeline, and the thing that moved quality the most was not a better model or a cleverer prompt. It was deciding that the model is not allowed to know anything it cannot cite.&lt;/p&gt;

&lt;p&gt;This post is the pattern I use to get there. It works with any LLM and any framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;p&gt;A language model writing from memory will invent facts that sound plausible. The fix is to retrieve real sources first, extract the facts you trust, and then force the model to write only against those facts. You build a small "fact context" per article and treat anything outside it as off-limits. I will show the three stages with code and the validation pass that catches what slips through.&lt;/p&gt;

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

&lt;p&gt;People frame hallucination as a model defect. It is, partly. But in a content pipeline it is mostly an architecture choice.&lt;/p&gt;

&lt;p&gt;When you send a prompt like "Write a 1500 word article about peptide bioavailability," you are asking the model to generate fluent text on a topic. Fluency is what it optimizes for. Truth is a side effect it reaches for from training data that may be old, averaged, or simply wrong for this specific claim.&lt;/p&gt;

&lt;p&gt;The model has no signal telling it "you do not actually know this number, so do not state it." So it states it anyway, in the same confident voice it uses for things it does know. That confidence is the dangerous part. A reader cannot tell a real stat from an invented one by tone alone.&lt;/p&gt;

&lt;p&gt;So the goal is not "make the model smarter." The goal is to remove its permission to free-associate facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: Retrieve before you write
&lt;/h2&gt;

&lt;p&gt;Before generating anything, gather real material about the topic. I pull from three source types because they fail in different ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A live web search for current facts and recent events&lt;/li&gt;
&lt;li&gt;An encyclopedia source for stable entities and definitions&lt;/li&gt;
&lt;li&gt;A synthesis source for harder questions that need reasoning across pages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is a trimmed version of the retrieval step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gatherSources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encyclopedia&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;synthesis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;braveSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;      &lt;span class="c1"&gt;// current facts&lt;/span&gt;
    &lt;span class="nf"&gt;wikipediaLookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;              &lt;span class="c1"&gt;// stable entities&lt;/span&gt;
    &lt;span class="nf"&gt;perplexityAsk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Key facts about &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; with sources`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// reasoning&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;normalizeSources&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encyclopedia&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;synthesis&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeSources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// keep token budget sane&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 output is a list of source objects, each with a URL and a chunk of real text. Nothing here is generated. It is all pulled from somewhere a human could go and verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: Extract the facts you trust
&lt;/h2&gt;

&lt;p&gt;Raw sources are noisy. The next step turns them into a short list of clean, attributed claims that the writer is allowed to use. I run this through the model too, but with a tight job: pull claims out, do not add any.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EXTRACT_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
You are a fact extractor. From the SOURCES below, list atomic factual
claims relevant to the topic "{{topic}}".

Rules:
- Every claim must be supported by the source text. Do not infer.
- Attach the source url to each claim.
- If a claim includes a number, the number must appear in the source.
- Do not add background knowledge. Only what is in the sources.

Return JSON: [{ "claim": string, "url": string }]
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractFacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sourceBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`SOURCE &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;):\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EXTRACT_PROMPT&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{{topic}}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;SOURCES:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;sourceBlock&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// drop anything the model failed to attribute&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;Now you have a fact sheet. Each item is a claim plus a URL. This is the only knowledge the writer gets to use. If a fact is not on this sheet, it does not go in the article.&lt;/p&gt;

&lt;p&gt;The reason this works: extraction is a much easier task than generation. Asking "is this claim in the text in front of you" is close to a lookup. Asking "write a true article about X" is open-ended. Narrow tasks hallucinate far less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: Write against the facts, not from memory
&lt;/h2&gt;

&lt;p&gt;Now generation. The prompt changes shape completely. Instead of "write about the topic," it becomes "write using only these approved facts."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WRITE_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
Write a section titled "{{heading}}" for an article about "{{topic}}".

You may ONLY use facts from the APPROVED FACTS list below.
- Do not introduce statistics, dates, or named studies that are not listed.
- If you need a fact that is not approved, write around it without inventing one.
- Keep claims attributable. Prefer specific over vague.

APPROVED FACTS:
{{facts}}
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;factList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;facts&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`- &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;WRITE_PROMPT&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{{topic}}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{{heading}}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{{facts}}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;factList&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 model still writes fluent prose. It still controls phrasing, flow, and structure. What it lost is the ability to reach outside the fact sheet for a number that "feels right."&lt;/p&gt;

&lt;p&gt;When the writer needs a statistic that is not approved, the instruction tells it to write around the gap instead of filling it with a guess. In practice this produces sentences like "peptide absorption varies with delivery method" instead of "peptide absorption improves by 4x with method Y," where the 4x was never real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validation pass that catches the rest
&lt;/h2&gt;

&lt;p&gt;Constraints reduce hallucination. They do not zero it out. So I run a cheap check after generation: pull every number and named claim out of the draft and confirm each one traces back to a source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateDraft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;extractClaimsFromDraft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// numbers + named facts&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;approved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsupported&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="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grounded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;overlaps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;grounded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;unsupported&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unsupported&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;unsupported&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// flag these for a rewrite or human review&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;Anything in &lt;code&gt;unsupported&lt;/code&gt; is a claim the writer produced that the fact sheet does not back. You can route those to a rewrite of just that paragraph, or to a human. Either way, the fabricated stat never reaches publish without someone seeing it first.&lt;/p&gt;

&lt;p&gt;This pass is where the client incident from the opening would have been caught. The fake "73%" would land in &lt;code&gt;unsupported&lt;/code&gt;, because no source ever contained it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few things that made it better
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Cap source text per chunk. Long sources blow your token budget and bury the useful lines. 4000 characters per source has been a fine ceiling for me.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Extract facts once, reuse across sections. Running extraction per section wastes calls and produces inconsistent fact sheets. One sheet per article keeps the whole piece consistent.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keep URLs attached the whole way through. Even if you never render citations, carrying the URL lets validation and human review trace any claim back fast.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Prefer "write around the gap" over "omit the fact." Telling the model to omit makes it drop whole sentences. Telling it to write around the gap keeps the prose flowing while staying honest.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Log the unsupported claims over time. The patterns tell you which topics your sources are too thin on, which is a retrieval problem, not a writing one.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Reality check: when this is overkill
&lt;/h2&gt;

&lt;p&gt;This pattern has real cost. Three retrieval calls, an extraction call, and a validation call sit on top of generation. That is latency and money per article. It is not free.&lt;/p&gt;

&lt;p&gt;Skip it when the content is not factual. Personal essays, opinion pieces, brand storytelling, and creative copy do not have facts to ground. Forcing a fact sheet onto a founder's opinion post just makes it stiff.&lt;/p&gt;

&lt;p&gt;Skip it when a human reviews every word anyway. If an editor fact-checks each article before it ships, the validation pass is redundant. The grounding still helps the first draft, but the full machinery earns its keep mostly at volume, where no human reads every line.&lt;/p&gt;

&lt;p&gt;And be honest about the ceiling. Grounding stops the model from inventing facts. It does not check whether your sources are correct. Garbage sources produce confidently grounded garbage. The retrieval quality is now your real bottleneck, which is a better problem to have but still a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack notes for the curious
&lt;/h2&gt;

&lt;p&gt;The version I run in production uses Brave Search for live web results, Wikipedia for entities, and Perplexity for synthesis questions. The extraction and writing both run on a frontier model with JSON mode for the structured steps. Sections generate in parallel, then stitch, which is a separate pattern worth its own post. The fact sheet is the spine that keeps the parallel sections from contradicting each other.&lt;/p&gt;

&lt;p&gt;I built this into Articfly (articfly.com) because at 63,000 articles across 9 retainer clients, one hallucinated stat per few hundred posts is still dozens of trust-breaking errors a year. The grounding layer is the difference between a tool a client can rely on and a toy that writes pretty lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to that client
&lt;/h2&gt;

&lt;p&gt;The article that cost me trust was fluent, well-structured, and wrong in exactly one sentence. That is the trap. Fluency hides the error. The reader trusts the tone.&lt;/p&gt;

&lt;p&gt;Retrieve-then-write does not make the model smarter. It changes what the model is allowed to claim. Once a fact has to exist in a real source before it can appear in the draft, the most damaging failure mode mostly goes away.&lt;/p&gt;

&lt;p&gt;If you are shipping AI content at any volume, what is your actual process for catching a fabricated fact before it goes live, or are you still trusting the tone?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The dead internet theory is half right</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Mon, 25 May 2026 16:34:43 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/the-dead-internet-theory-is-half-right-3id7</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/the-dead-internet-theory-is-half-right-3id7</guid>
      <description>&lt;p&gt;Last year I built a tool that has now shipped 50,000 AI-written articles for paying customers. So when developers post the dead internet theory on Twitter, I am one of the people being accused of killing the web.&lt;/p&gt;

&lt;p&gt;After a year of watching how real users actually use the thing (logs, edit patterns, traffic, conversions), I have to admit it: half of the dead internet panic is correct. The other half is a vibes-based moral panic that does not survive contact with data.&lt;/p&gt;

&lt;p&gt;This post is the engineering view. The patterns that produce slop versus the patterns that produce content people actually finish reading. With code.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AI content farms got worse. That part of the dead internet theory is real.&lt;/li&gt;
&lt;li&gt;But "AI content" and "AI-assisted content" produce different traffic, conversion, and decay curves. The panic blurs them.&lt;/li&gt;
&lt;li&gt;The technical pattern that separates the two is not which model you use. It is whether you generate as one monolithic prompt or as a chained pipeline with per-section briefs, voice constraints, and fact pinning.&lt;/li&gt;
&lt;li&gt;I will show the difference with actual prompt code below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the dead internet theory is right
&lt;/h2&gt;

&lt;p&gt;Before GPT-3.5, a content farm needed five cheap writers on Upwork. Now it needs one prompt and a CMS.&lt;/p&gt;

&lt;p&gt;The volume of low-effort AI text on the open web has roughly 10x'd since November 2022. Google search results page 1 for low-intent queries ("best CRM 2025", "how to drink water", "is X a good idea") is now mostly garbage. Comment sections on big-traffic sites trend 30 to 50 percent bots talking to bots. LinkedIn is a hallucinated thought-leader loop. Reddit is laundering generated content as personal stories.&lt;/p&gt;

&lt;p&gt;I do not pretend my tool has nothing to do with this. In the hands of someone who clicks generate, picks the cheapest title, and publishes without reading, my product is part of the problem.&lt;/p&gt;

&lt;p&gt;That is not the full picture though.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the dead internet theory is wrong
&lt;/h2&gt;

&lt;p&gt;The panic treats every AI-written word as identical garbage. From my logs across 50,000 published articles, that is not what happens.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;70 percent of articles get manual edits before publish. People rewrite intros, fix specific claims, swap headlines, add personal context.&lt;/li&gt;
&lt;li&gt;The top 20 percent of accounts (by retention and traffic) edit harder than they generate. They use the tool for a 60 percent draft and then spend 45 to 90 minutes on the post.&lt;/li&gt;
&lt;li&gt;The 10 percent who publish raw also see the worst traffic. Google demoted that layer in the September 2024 helpful content update and never undid it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The internet that pays (places where someone buys something, signs up, or hires you) does not run on raw AI slop. It runs on AI as a draft layer plus a human who knows what they are saying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual technical difference
&lt;/h2&gt;

&lt;p&gt;Here is the part nobody outside the AI writing tools space talks about. There is a difference between AI content and AI-assisted content, and it is not vibes. It is a prompt architecture difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  The slop pattern: monolithic prompt
&lt;/h3&gt;

&lt;p&gt;This is what the dead internet theory is mostly worried about. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateSlop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wordCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Write a &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; word article about &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.
Include an introduction, 5 sections, and a conclusion.
Use SEO best practices. Make it engaging.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-3-5-sonnet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces output that is technically grammatical English and technically about the topic. It also produces output that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drifts off topic between section 3 and section 5 because the model loses attention on long generations.&lt;/li&gt;
&lt;li&gt;Repeats the same generic claims across all sections (it has no shared brief to constrain it).&lt;/li&gt;
&lt;li&gt;Includes hallucinated statistics ("studies show 73 percent of users prefer X").&lt;/li&gt;
&lt;li&gt;Reads identically across topics because the prompt has no voice constraints.&lt;/li&gt;
&lt;li&gt;Cannot be updated without regenerating the entire article.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the slop the dead internet panic is rightly angry about. It floods low-intent SERPs. It does not convert. It does not retain readers. It does not build a brand.&lt;/p&gt;

&lt;h3&gt;
  
  
  The useful pattern: per-section briefs
&lt;/h3&gt;

&lt;p&gt;Same model, same goal, but a chained pipeline with shared state. This is what actually works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ArticleBrief&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;targetKeyword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;voiceExamples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;factualAnchors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;forbiddenTerms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SectionBrief&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;keyPoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;targetWords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateOutline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArticleBrief&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SectionBrief&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outlinePrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildOutlinePrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outlinePrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&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;parseSections&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArticleBrief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SectionBrief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Write the section "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" only.
Target length: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetWords&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; words.

Purpose: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Key points to cover: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyPoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;

Voice constraints. Match the rhythm and vocabulary of these samples:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;formatVoiceExamples&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;voiceExamples&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;

Factual anchors. You must cite these or stay silent on the topic:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;formatAnchors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;factualAnchors&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;

Forbidden terms (rewrite if any appear): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forbiddenTerms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
`&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;callModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetWords&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArticleBrief&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateOutline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bodies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;generateSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stitch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bodies&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;What this changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each section gets focused attention because it is a separate model call with a tight word budget.&lt;/li&gt;
&lt;li&gt;The article brief is shared state, so every section knows the voice, the anchors, the banned words.&lt;/li&gt;
&lt;li&gt;The outline step forces a top-down structure before any prose exists.&lt;/li&gt;
&lt;li&gt;You can regenerate one section without trashing the rest.&lt;/li&gt;
&lt;li&gt;Hallucinations drop sharply because the model is constrained to factual anchors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built this because I had to. The first version of my pipeline used a monolithic prompt. Customer retention was bad. Traffic was bad. Edit rate was high enough that users complained the tool was not useful. The second version (per-section briefs with shared state) is what 50,000 articles have shipped through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this matters for the dead internet theory
&lt;/h3&gt;

&lt;p&gt;The monolithic pattern produces dead-internet content. The chained pattern produces something closer to a junior writer's first draft. Both use the same underlying model. The technical architecture is what separates them.&lt;/p&gt;

&lt;p&gt;When people post the dead internet theory, they are usually reacting to monolithic-prompt output. That is the genre of content that floods low-quality SERPs. They are not usually reacting to chained-pipeline output, because chained-pipeline output gets edited by a human before publish and looks like normal modern writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reality Check (when the dead internet panic IS right)
&lt;/h2&gt;

&lt;p&gt;I am not arguing AI writing is fine. There are categories where it absolutely is the problem.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pure SEO content farms.&lt;/strong&gt; No human in the loop, no editing, hundreds of articles per week. This is dead internet content even with a chained pipeline. Volume kills value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Astroturf comments.&lt;/strong&gt; Generated forum posts pretending to be a real user with a story. This is unambiguously bad and the panic is correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hallucinated expert content.&lt;/strong&gt; AI-written medical, legal, financial advice with no human review. The technical pipeline does not save you here. You need a domain expert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI-on-AI loops.&lt;/strong&gt; SEO content scraping other SEO content, scoring AI against AI. Eventually the entire signal degrades because nothing in the loop is grounded in reality.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are doing any of these, the dead internet theory is your fault and you should stop. My own product has terms against the first two.&lt;/p&gt;

&lt;p&gt;The complaint that gets it wrong is the version that says "any AI-written word is killing the web". A human using a per-section pipeline to skip the empty first draft is not the same person running a content farm. The data is not the same. The output is not the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually tell customers
&lt;/h2&gt;

&lt;p&gt;Three rules. I repeat them on every onboarding call.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The AI does the first draft. You do the last 30 percent. Always.&lt;/li&gt;
&lt;li&gt;Specific numbers, named people, and personal context are the parts AI cannot fake. Put them in the post yourself.&lt;/li&gt;
&lt;li&gt;If your edit rate is below 30 percent, you are about to get demoted by the next Google update. Bring it up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is also why I do not market my product as "10x content output" or "AI articles on autopilot". Both of those framings produce the bad version of customer behavior. I market it as a draft layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  So is the internet dead
&lt;/h2&gt;

&lt;p&gt;The cheap layer of it got cheaper, faster, and more annoying. That layer was already empty calories before AI. Now there is more of it.&lt;/p&gt;

&lt;p&gt;The parts that pay (specific expertise, real numbers, named people, brand voice, technical depth) are harder to fake. That layer still rewards humans who write things only they can write.&lt;/p&gt;

&lt;p&gt;The dead internet theory describes the empty-calories layer of the internet. It does not describe the entire internet, even though the panic says it does. Both of these can be true at the same time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI writing tools have polluted low-intent SERPs.&lt;/li&gt;
&lt;li&gt;AI writing tools, used with a real pipeline and a real human, are a draft layer that helps writers ship more of the work they are already capable of.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building anything in this space, the architecture you ship determines which side of that line your product lands on.&lt;/p&gt;

&lt;p&gt;If you are publishing content, your edit rate determines the same thing.&lt;/p&gt;

&lt;p&gt;What is your edit rate when you use AI for a draft? Be honest. And if you have shipped a chained-pipeline architecture that works better than per-section briefs, I want to hear about it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>programming</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Anatomy of a form POST: 9 things that fire before your inbox pings</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Thu, 21 May 2026 11:04:26 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/anatomy-of-a-form-post-9-things-that-fire-before-your-inbox-pings-2l85</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/anatomy-of-a-form-post-9-things-that-fire-before-your-inbox-pings-2l85</guid>
      <description>&lt;p&gt;Last March I spent forty minutes tracing one POST request through my own form backend and found three bugs along the way. The request looked simple from the outside. HTML form, action attribute, submit button, email lands. From the inside it was nine separate operations firing in sequence and parallel, with four ways to silently lose the submission and one race condition I had shipped to production without noticing.&lt;/p&gt;

&lt;p&gt;This is the walkthrough I wish I had when I started. If you are building anything that takes user input over HTTP (contact forms, signup flows, file uploads, lead capture), most of the work happens in places you cannot see until something breaks. Here is everything that runs between the browser hitting Submit and the form owner getting an email, in execution order, with the code patterns and the failure modes for each step.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;p&gt;A modern form backend is not a single endpoint. It is a pipeline of nine stages: rate limit, automation fingerprint, multipart parsing with magic-byte validation, honeypot, optional CAPTCHA, adaptive spam heuristics, database insert, async email notification, and async fanout to webhooks plus chat integrations. Some run sync on the hot path. Most should not. The order matters more than the individual layers.&lt;/p&gt;

&lt;p&gt;I built this for FormTo, an open source Formspree alternative. Code on GitHub at github.com/lumizone/formto. Every snippet below is real production code, simplified for clarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Rate limit check
&lt;/h2&gt;

&lt;p&gt;The first thing a POST handler should do is reject obvious abuse. Cheap to compute, cheap to fail, runs before you touch the body.&lt;/p&gt;

&lt;p&gt;The key shape matters more than the limit numbers. Two patterns are tempting and both are wrong on their own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrong: key only on IP&lt;/span&gt;
&lt;span class="c1"&gt;// one shared NAT gateway throttles every legitimate user behind it&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`submission:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;

&lt;span class="c1"&gt;// wrong: key only on endpoint&lt;/span&gt;
&lt;span class="c1"&gt;// one attacker takes the form down for everyone&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`submission:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct shape is both axes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`submission:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too many requests&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five requests per IP per endpoint per sixty seconds. Returns 429 if exceeded. Sync, blocks the request. The whole check is one round trip to Redis and adds maybe two milliseconds on the hot path.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Automation fingerprint
&lt;/h2&gt;

&lt;p&gt;After rate limit, look at the user agent. Most spam fails here before doing any real work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;automationSignatures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="sr"&gt;/curl/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/python-requests/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/go-http-client/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/node-fetch/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/axios/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/httpie/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/headlesschrome/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/phantomjs/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/puppeteer/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/scrapy/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/wget/i&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;automationSignatures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;re&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Automation detected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a strong signal on its own. Determined spammers spoof user agents. The point is it catches the lazy 60% for free before you spend a CPU cycle on parsing or storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Multipart parsing and file upload
&lt;/h2&gt;

&lt;p&gt;If the form has file inputs, the body is multipart/form-data and you cannot just call JSON.parse. You need a streaming parser.&lt;/p&gt;

&lt;p&gt;Three things go wrong here in production.&lt;/p&gt;

&lt;p&gt;First, MIME validation. The &lt;code&gt;Content-Type&lt;/code&gt; header on each file part is client-controlled. A real bot uploads a PHP shell with &lt;code&gt;Content-Type: image/jpeg&lt;/code&gt;. The fix is to validate the file by reading its first eight bytes (magic numbers) and matching against a known list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;magicBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xD8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;png&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mh"&gt;0x89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x4E&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x47&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mh"&gt;0x25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x46&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;gif&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mh"&gt;0x47&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x49&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x46&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x38&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateMagicBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;claimedType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;magicBytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;claimedType&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;byte&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;Second, size limits. The default streaming parsers in Fastify and Multer let everything through unless you configure a cap. Set a per-plan limit (10MB for free users, 25MB for paid, whatever your numbers look like) and reject early. A 50GB upload that bursts memory before you check is your fault, not the attacker's cleverness.&lt;/p&gt;

&lt;p&gt;Third, cleanup. The streaming parser writes to a temp file first. Forget the &lt;code&gt;finally&lt;/code&gt; block that deletes it and you fill the disk in a week. Ask me how I know.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Honeypot check
&lt;/h2&gt;

&lt;p&gt;A honeypot is an invisible form field that humans cannot see (CSS hidden, off-screen positioned, autocomplete tricks). Bots fill in every input they find. If the honeypot has a value, it is a bot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;honeypotFields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_gotcha&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;honeypot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;phone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fax&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;company&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subject&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;address&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fullname&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nickname&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;middlename&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;comment2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email_confirm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isHoneypotTriggered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;honeypotFields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair it with a timing check. Add a hidden timestamp field on page load and compare against the server time on submit. Anything under three seconds is almost always a bot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formAgeMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_formto_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isTooFast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;formAgeMs&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clever part is what you return when either trips. Returning 400 tells the bot to mutate inputs and retry. Returning 200 with a fake submission ID convinces the bot the spam landed. It moves on. This single layer catches roughly 80% of the spam I see at zero CPU cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. CAPTCHA verification
&lt;/h2&gt;

&lt;p&gt;Only run this if the form owner enabled it. CAPTCHA costs latency and a provider API call, so I gate it behind a paid plan.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyTurnstile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://challenges.cloudflare.com/turnstile/v0/siteverify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TURNSTILE_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;remoteip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap is that this call sits on the hot path. A Cloudflare or Google API hiccup adds 800 milliseconds to every submission. Set an aggressive timeout (1.5s tops) and decide explicitly what happens if the verification request fails. Fail open and you let bots through. Fail closed and a CAPTCHA outage takes your form down. Pick one. Do not let the network decide for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Adaptive spam score
&lt;/h2&gt;

&lt;p&gt;What honeypots and CAPTCHA miss, heuristics catch. This is the layer that does the most work without ever showing a challenge to a human.&lt;/p&gt;

&lt;p&gt;The score is a stack of signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Temp-email domain (mailinator, 10minutemail, guerrillamail): +3&lt;/li&gt;
&lt;li&gt;Headless browser hints in user agent: +2&lt;/li&gt;
&lt;li&gt;Link density above 30% of message body: +4&lt;/li&gt;
&lt;li&gt;Missing Origin and Referer headers: +1&lt;/li&gt;
&lt;li&gt;Burst: 8 submissions from one client in 300s: blocked&lt;/li&gt;
&lt;li&gt;Burst: 4 submissions from one email in 600s: blocked&lt;/li&gt;
&lt;li&gt;Duplicate fingerprint within 60s: blocked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fingerprint is a SHA256 of (form ID + client fingerprint + body + file hash). Same fingerprint inside the dedupe window means the user double-clicked or a bot is replaying a single payload.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;formId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fileHash&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&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="s2"&gt;`dedupe:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// duplicate within 60s, silently accept (fake 200, bot moves on)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fakeId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Score above five blocks. Score above three blocks if the request also tripped step 1 or step 2. The goal is to leave humans alone and burn bots.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Submission insert
&lt;/h2&gt;

&lt;p&gt;Now you are committed. The request looked legitimate enough to store.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submission&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submissions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;form_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;form_endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cleanedData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;file_urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;uploadedFiles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;spam_risk_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;spam_signals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signals&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The metadata column is the part you will wish you had during incidents. Save more than feels necessary. The spam risk score in particular is gold when a customer complains about missing submissions and you can show them the row was scored, accepted, and emailed.&lt;/p&gt;

&lt;p&gt;One race condition burned me here. Forms with a "close after N submissions" feature: two concurrent submitters both check the count, both see it under the limit, both insert. The fix is to enforce the limit inside a Postgres trigger with row-level locking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;enforce_submission_limit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
  &lt;span class="n"&gt;current_count&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;max_count&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;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;close_after_submissions&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;max_count&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;forms&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;form_id&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;max_count&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;current_count&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;submissions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;form_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;form_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;current_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_count&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'FORM_SUBMISSION_LIMIT_REACHED'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; locks the form row until the transaction commits. Concurrent inserts wait their turn instead of all passing the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Email notify (fire and forget)
&lt;/h2&gt;

&lt;p&gt;Notifications are the visible product. They are also the thing most likely to fail silently.&lt;/p&gt;

&lt;p&gt;The temptation is to &lt;code&gt;await&lt;/code&gt; the email send so you can return a clean error to the client. Do not. Network calls to Resend, SendGrid, or Postmark add 200 to 400 milliseconds. Multiply by your traffic and your forms feel slow for no good reason.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// fire after the response is on its way back to the browser&lt;/span&gt;
&lt;span class="nf"&gt;sendNotificationEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notification_emails&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_template_enabled&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_template&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;defaultTemplate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cleanedData&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notification email failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// alert if failure rate crosses 1% in a 5min window&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dangerous part is that fire-and-forget is a lie if you do not track what fired. If you catch the error, log it, and never look at the log, every silent failure looks like a happy path. Add a &lt;code&gt;notification_status&lt;/code&gt; field to the submission: pending, sent, failed. Review the failed ones weekly. A proper retry queue is the real answer once you have volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Fanout (Slack, Discord, Telegram, webhook)
&lt;/h2&gt;

&lt;p&gt;Everything in this step runs after the response is already on its way back to the browser. All async, all fire-and-forget, all with timeouts.&lt;/p&gt;

&lt;p&gt;The webhook is the only one with a security model worth showing. The form owner registers a secret. Every webhook delivery includes a timestamp header and an HMAC-SHA256 signature of &lt;code&gt;timestamp.body&lt;/code&gt; signed with that secret.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form.submitted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;submission&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhook_secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhook_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-formto-timestamp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-formto-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&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 receiver computes the same signature with the same secret and compares with &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;. If it matches, the request is from you. If it does not, somebody is replaying.&lt;/p&gt;

&lt;p&gt;Slack, Discord, and Telegram get block-formatted messages with cruel field limits you only discover in production. Slack caps message blocks at fields[8]. Discord caps embed fields at 15. Telegram messages cap at 4000 characters total. Spammers send forms with 200 fields. Truncate or your formatter throws.&lt;/p&gt;

&lt;h2&gt;
  
  
  The response
&lt;/h2&gt;

&lt;p&gt;201 Created. JSON body with three keys: &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;submissionId&lt;/code&gt;, and an optional &lt;code&gt;redirect&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;submission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redirect_url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate the redirect URL server-side when the form is configured, never accept it from form data on submit. The &lt;code&gt;javascript:&lt;/code&gt;, &lt;code&gt;data:&lt;/code&gt;, and unicode-spoofed domains live there.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should NOT build this yourself
&lt;/h2&gt;

&lt;p&gt;This is the part most posts skip. After the trace was done and the bugs were fixed, I asked myself if it was worth it. The honest answer for most people is no.&lt;/p&gt;

&lt;p&gt;Build your own form backend when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have an unusual storage or compliance requirement (data residency in a specific region, on-prem, custom encryption-at-rest)&lt;/li&gt;
&lt;li&gt;You are already running infrastructure that this slots into (your own Postgres, your own queue, your own observability)&lt;/li&gt;
&lt;li&gt;You need to integrate the submission step into a longer workflow that does not exist in any hosted product&lt;/li&gt;
&lt;li&gt;You have a team of two or more developers who can carry the operational load (paging, monitoring, security patches, customer reports)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Buy a hosted form backend (Formspree, Basin, Web3Forms, FormTo, Netlify Forms) when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are a solo developer or an indie maker shipping a marketing site, a landing page, or a contact form&lt;/li&gt;
&lt;li&gt;The form is not the product, it is a thing the product needs&lt;/li&gt;
&lt;li&gt;You bill yourself for your own time at any rate above zero&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built mine because the form backend IS the product. If I were running a marketing site I would pay nine dollars a month to make this problem go away. The pipeline above is two months of bug fixes and three rewrites in disguise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three lessons from rewriting this
&lt;/h2&gt;

&lt;p&gt;The order matters more than the layers. Run cheap checks (rate limit, fingerprint, honeypot) before expensive ones (CAPTCHA, storage, DB). Bots get rejected in five milliseconds, humans never notice the difference.&lt;/p&gt;

&lt;p&gt;Fire and forget is a lie. If you do not track what fired and what failed, every silent failure looks like a happy path. Add a status field to every async job. Look at the failures once a week. The customers who quietly leave because notifications stop working will never tell you that is why.&lt;/p&gt;

&lt;p&gt;The response shape is part of the API even when the API is public. Bots watch what you return. Returning 200 with a fake ID for honeypot hits is more effective than returning 400, because failure tells them to mutate and retry. Lying is a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to the forty minutes
&lt;/h2&gt;

&lt;p&gt;The three bugs I found tracing that one POST request: a missing carriage-return strip in the autoresponder subject (header injection waiting to happen), an &lt;code&gt;await&lt;/code&gt; on the email send that pushed every submission to a 600ms response time, and the missing row-level lock on the close-after-N counter. All three were one-line fixes. All three had been in production for weeks.&lt;/p&gt;

&lt;p&gt;I kept the trace as a runbook for my future self. The next time something looks slow or something looks lost, I have the map.&lt;/p&gt;

&lt;p&gt;If you write form backends by hand, what is the bug you wish you had caught earlier?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How 6 AI agents write a single blog post (and why</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Thu, 21 May 2026 10:44:01 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/how-6-ai-agents-write-a-single-blog-post-and-why-7fa</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/how-6-ai-agents-write-a-single-blog-post-and-why-7fa</guid>
      <description>&lt;p&gt;About a year ago I shipped a one-prompt blog writer. It worked once.&lt;/p&gt;

&lt;p&gt;The second article sounded identical to the first. The third article sounded identical to the second. By the tenth article, every blog I was generating could be mistaken for the same writer with mild amnesia.&lt;/p&gt;

&lt;p&gt;That was the moment I started ripping the single prompt apart.&lt;/p&gt;

&lt;p&gt;What replaced it is a backend that runs six specialized agents per article. Each one has a narrow job. None of them sees the whole article. The output reads like a human wrote it because the steering is human-shaped all the way down, not because any single model magically learned voice.&lt;/p&gt;

&lt;p&gt;This is a walk-through of that pipeline. The exact agents, what they do, why they exist, and what each one breaks if you remove it.&lt;/p&gt;

&lt;p&gt;TLDR: one giant prompt is the reason your AI articles sound the same. Split the job into agents with their own contracts, and the output starts behaving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with a single prompt
&lt;/h2&gt;

&lt;p&gt;A naive AI writer looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Write a 1500 word blog post about &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.
           Use these keywords: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Match this tone: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs in one call. The model gets the whole job in one shot. The output gets returned.&lt;/p&gt;

&lt;p&gt;It works. It also produces the same article every time, with cosmetic surface changes.&lt;/p&gt;

&lt;p&gt;A 1500 word prompt to a single model produces a 1500 word output that follows the model default essay shape. The intro is always a setup. The conclusion is always a recap.&lt;/p&gt;

&lt;p&gt;The body always builds in a smooth gradient. The vocabulary stays in the model safe band. The sentence rhythm averages out.&lt;/p&gt;

&lt;p&gt;You can prompt against all of this. You can ask for variety. You can ask for "no AI tells".&lt;/p&gt;

&lt;p&gt;The model nods politely and writes another essay that looks exactly like the last one.&lt;/p&gt;

&lt;p&gt;The fix is not a better prompt. The fix is to stop treating one model call as the unit of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent 1: Research
&lt;/h2&gt;

&lt;p&gt;The first agent does not write. It searches.&lt;/p&gt;

&lt;p&gt;Input: topic and a short description from the user.&lt;/p&gt;

&lt;p&gt;Output: a research brief with real URLs, real quotes, real numbers, and optional canonical links for the strongest claims.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;research&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;braveSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;wikipediaSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;perplexityQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rankAndDedupe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sources&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;Why a dedicated agent: a model writing without grounded research hallucinates names, numbers, and citations. A research pass gives the rest of the pipeline a fact substrate. If the substrate is empty, the post gets flagged as "no sources found" and the user is asked to refine the topic.&lt;/p&gt;

&lt;p&gt;What breaks if you remove it: every article reverts to model priors. Same three founders quoted. Same dates wrong. Same fake statistics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent 2: Structure
&lt;/h2&gt;

&lt;p&gt;Second agent does not write either. It outlines.&lt;/p&gt;

&lt;p&gt;Input: the research brief plus the topic.&lt;/p&gt;

&lt;p&gt;Output: an H2 outline. Each H2 gets a one-line angle, a target word count, and a list of entities that should appear inside that section.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;research&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You produce blog post outlines. Return JSON.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;outlinePrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;research&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;responseFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json_schema&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outlineSchema&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outline is treated as a contract. Downstream agents have to honor it. No section creep. No drifting into new themes. No skipping a planned H2 because the model felt the article was complete.&lt;/p&gt;

&lt;p&gt;Why this agent exists: a single-prompt model picks its own structure mid-paragraph, which is why two articles on the same topic end up structurally identical. An explicit outline forces structural variety to come from the topic and not from the model.&lt;/p&gt;

&lt;p&gt;What breaks if you remove it: every article follows the same intro / three-part body / conclusion shape. AI detectors pick this up first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent 3: Section briefer
&lt;/h2&gt;

&lt;p&gt;The brief agent expands each outline item into a per-section brief.&lt;/p&gt;

&lt;p&gt;A section brief looks 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;"h2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"How to set up the webhook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"angle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Concrete walk-through, not a theory primer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"wordCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requiredEntities"&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;"Stripe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Express"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"raw body parser"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bannedPhrases"&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;"let us explore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"in this section"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"diving in"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"codeBlocks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"instructional"&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;Each brief is also a contract. The section writer agent only gets one brief at a time. It does not see the whole outline. It does not see the other sections. It writes for the brief in front of it.&lt;/p&gt;

&lt;p&gt;Why this agent exists: it strips the model essay-shape reflex. A model writing one paragraph against a tight brief produces a tight paragraph. A model writing a whole article in one go produces an essay.&lt;/p&gt;

&lt;p&gt;What breaks if you remove it: sections lose their distinct voice and collapse back into one homogenous middle. The intro and conclusion get fatter than they should. Examples get washed out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent 4: Section writer
&lt;/h2&gt;

&lt;p&gt;Now we write. One agent call per section.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeSection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;voiceProfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prevSection&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;voiceProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sectionPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prevSection&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The writer gets the section brief, the voice profile, and a short summary of the previous section so transitions feel intentional. It does not get the full article so far. It does not get the outline. Its job is small enough to stay focused.&lt;/p&gt;

&lt;p&gt;This is the agent most teams skip. They keep the single-prompt approach for the actual writing step and only wrap research and outline agents around it. The result still sounds like the same writer because the writer step is still doing the bulk of the cognitive work in one shot.&lt;/p&gt;

&lt;p&gt;What breaks if you remove it: rhythm uniformity. Vocabulary uniformity. Paragraph length uniformity. The exact signals AI detectors and human readers both pick up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent 5: Voice profile
&lt;/h2&gt;

&lt;p&gt;The voice agent runs offline, before any article generation, against the user own published writing.&lt;/p&gt;

&lt;p&gt;Input: 10 to 20 URLs from the user existing blog or site.&lt;/p&gt;

&lt;p&gt;Output: a JSON voice profile.&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;"averageSentenceLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vocabularyTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"casual-technical"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preferredOpeners"&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;"I"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"We"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Last"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Honestly"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"avoidWords"&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;"actually"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"basically"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stuff"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"punctuationPatterns"&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;"semicolons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rare"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"exclamation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"never"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"questionsInBody"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yes"&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;"systemPrompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"You are writing in the voice of..."&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;Every section writer call uses this profile as its system prompt. Two users with different blogs get two completely different article shapes from the same backend.&lt;/p&gt;

&lt;p&gt;Why this agent exists: it is the only mechanism that gives the output a non-generic voice. Brand voice cannot be prompted inline. It has to be baked into the system message, derived from real writing, and applied at every section call.&lt;/p&gt;

&lt;p&gt;What breaks if you remove it: every article from every user starts sounding like the same model. The product loses its main differentiator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent 6: Polish and artifact strip
&lt;/h2&gt;

&lt;p&gt;Final agent runs a deterministic post-process pass, not a model call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;polish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;voiceProfile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;2014&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2013&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// em and en dashes&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2026/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// ellipsis&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;201C&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;201D&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// smart double quotes&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;2018&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2019&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// smart single quotes&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stripBannedOpeners&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;voiceProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avoidWords&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;enforceSentenceLengthVariance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pass strips the obvious AI tells. Em dashes. Smart quotes. Sentence-initial connectors the voice profile banned. It also enforces sentence-length variance so paragraphs do not settle into the model preferred rhythm.&lt;/p&gt;

&lt;p&gt;A model could do this step. A deterministic pass is cheaper, faster, and never reintroduces what it just removed. Use a model when you need judgment. Use code when you need rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to use this
&lt;/h2&gt;

&lt;p&gt;Six agents per article is expensive. If you are writing one blog post a week for your own site, this is heavyweight engineering for a problem you could solve by editing a single-prompt output yourself.&lt;/p&gt;

&lt;p&gt;Use the multi-agent pipeline when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You ship dozens to thousands of articles a month&lt;/li&gt;
&lt;li&gt;The output has to read consistently across many users&lt;/li&gt;
&lt;li&gt;Voice has to vary per customer&lt;/li&gt;
&lt;li&gt;Detection or ranking risk is real&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip the pipeline when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You write one to five articles a month&lt;/li&gt;
&lt;li&gt;A human will edit every output anyway&lt;/li&gt;
&lt;li&gt;Latency matters more than quality (a one-shot prompt returns in three seconds, this pipeline takes thirty plus)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pipeline is also overkill for short formats. Tweets, captions, ad copy, email subject lines. A single prompt is fine there because the cognitive work fits in one call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually costs
&lt;/h2&gt;

&lt;p&gt;For reference, one article through the six-agent pipeline runs about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 to 8 search calls (research agent)&lt;/li&gt;
&lt;li&gt;1 outline call&lt;/li&gt;
&lt;li&gt;5 to 10 brief calls (one per H2)&lt;/li&gt;
&lt;li&gt;5 to 10 section calls&lt;/li&gt;
&lt;li&gt;0 model calls in polish (deterministic)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per-article cost lands around 12 to 25 cents at current GPT-4 class pricing. Total wall-time runs 25 to 45 seconds when the section calls run in parallel.&lt;/p&gt;

&lt;p&gt;A single-prompt approach costs around 1 to 2 cents per article and finishes in 5 seconds. The price gap is real. The output gap is also real. Pick which one your product needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The article you are reading
&lt;/h2&gt;

&lt;p&gt;Eleven months after I gave up on the single prompt, I have shipped more than 50,000 articles through this pipeline at articfly.com. The articles read like the customers wrote them because the steering profile was extracted from the customer writing. The model still does most of the typing. The pipeline does most of the thinking.&lt;/p&gt;

&lt;p&gt;The second article does not sound like the first one anymore. Neither does the ten thousandth.&lt;/p&gt;

&lt;p&gt;If you are about to build an AI writer and you are starting with a single prompt, you are about to learn this the slow way. Skip the year. Split the prompt.&lt;/p&gt;

&lt;p&gt;What is the one agent in your stack that you wish you had split out earlier?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>I built an AI narrative story app because life lacks adventure</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Tue, 19 May 2026 17:52:47 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/i-built-an-ai-narrative-story-app-because-life-lacks-adventure-39nf</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/i-built-an-ai-narrative-story-app-because-life-lacks-adventure-39nf</guid>
      <description>&lt;p&gt;So I built this little thing called an AI narrative story maker and I want to be honest about why.&lt;/p&gt;

&lt;p&gt;I grew up on fantasy books, manga, manhwa, anime, JRPGs, MMOs. Like every kid who reads too much, I spent half my brain wondering what I would do if I were in there. What would I do at that tavern? Which side would I pick? Would I trust the cursed knight? Childish, sure, but if you're reading this you've probably done the same.&lt;/p&gt;

&lt;p&gt;Then you grow up, you stop. You read a manhwa here and there. Life happens.&lt;/p&gt;

&lt;p&gt;Recently I finally managed to leave my 9-5 and suddenly I had time again. As a long-time fan of Omniscient Reader's Viewpoint (if you know, you know), I had this stupid idea. What if I built a kind of story-RPG where the AI is the narrator and you're the character who decides what happens next. I know plenty of apps already do this. But I always wanted to make a game, always wanted to write a fantasy book, and I never did either. So this is more about doing the thing than about market logic.&lt;/p&gt;

&lt;p&gt;The app is called First Person Viewpoint (FPV). It's on iOS and Android. You make up your own world, create your character, and the AI handles the narration while you take actions. Basically the same family as AI Dungeon and the others, but vertical-scroll, no chat bubbles, reads more like a manhwa than a chat window.&lt;/p&gt;

&lt;p&gt;Genres are pretty open: fantasy, sci-fi, romance, manga, horror, custom, even NSFW (the app is 18+ only). There's a small community feed where users can publish their own worlds.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Honest about the business side
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;We don't own an LLM. We're not OpenAI. We use third-party APIs and somebody has to pay the per-token bill.&lt;/li&gt;
&lt;li&gt;Free tier has ads, just enough to cover token cost on free users so I don't have to sell a kidney every month.&lt;/li&gt;
&lt;li&gt;Paid tiers are ad-free with more actions and a better narration model.&lt;/li&gt;
&lt;li&gt;We don't sell your data. Sessions live mostly on your device, the server only has what it needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's pretty fair I think. I'm not trying to flip it for a billion. I just wanted it to exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually love feedback on
&lt;/h2&gt;

&lt;p&gt;Right now most users are friends and family. If you have iOS or Android and you want to try it, I'd be very grateful for any honest reaction. If something feels broken or confusing, even more grateful, that's the feedback that actually helps a solo dev.&lt;/p&gt;

&lt;p&gt;The things that keep me up at night:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first 60 seconds of onboarding. Does the "you are the character" framing land or feel like work?&lt;/li&gt;
&lt;li&gt;The "older text fades to navy" effect. Cool or annoying?&lt;/li&gt;
&lt;li&gt;Free tier at 250 actions per month. Too tight or about right?&lt;/li&gt;
&lt;li&gt;Pricing. Premium is $9.99 and Max is $24.99. Does that read as fair or as another AI tax?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this kind of stack is wrong
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend this approach fits every project. If you're considering shipping a solo AI app, here is when I'd say do not bother:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need offline first. LLM-driven apps need a network round trip per action. Not viable on a plane.&lt;/li&gt;
&lt;li&gt;You need real-time voice. Text-stream latency is fine for prose, terrible for live conversation.&lt;/li&gt;
&lt;li&gt;You can't tolerate per-token costs that scale with usage. Pick a flat-fee API or run your own model on a fixed-cost server. The unit economics of a free tier paid for by ads are tight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everyone else, the maths is doable. The pieces exist. The hard part is the taste decisions, not the wiring.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  So
&lt;/h2&gt;

&lt;p&gt;I built the game I always wished existed. It is live. People are playing it. The kid in me who used to sit in the tavern wondering which faction to join finally got to actually decide.&lt;/p&gt;

&lt;p&gt;If you have shipped a solo project that started as a "stupid idea you couldn't shake", I want to hear it. What is the dumb thing you finally got around to making?&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>indiehackers</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Why I built my SaaS without TypeScript on the frontend (and don't regret it)</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Tue, 19 May 2026 13:34:28 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/why-i-built-my-saas-without-typescript-on-the-frontend-and-dont-regret-it-2fp8</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/why-i-built-my-saas-without-typescript-on-the-frontend-and-dont-regret-it-2fp8</guid>
      <description>&lt;p&gt;I shipped my SaaS dashboard in plain JavaScript. React 19, JSX, no &lt;code&gt;.ts&lt;/code&gt; files, no &lt;code&gt;tsconfig.json&lt;/code&gt;, no type imports. The marketing site that surrounds it is full TypeScript. The backend is a JavaScript codebase with TypeScript creeping in addtively. Three codebases, three decisions, one indie hacker.&lt;/p&gt;

&lt;p&gt;The dashboard is the most opinionated part. Almost every dev.to comment thread I have read on this topic ends in "just use TypeScript, it costs nothing". I disagree, and I want to walk through why for the specific case of a solo-built SaaS dashboard.&lt;/p&gt;

&lt;p&gt;This is not an anti-TypeScript post. The marketing site sitting next to the dashboard is TypeScript. The backend is moving to TypeScript file by file. The case I am making is narrower: &lt;strong&gt;on a fast-moving, small-team, internal-only React SPA, JavaScript was the right pick, and I would do it again.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;To make the rest of this make sense, here is the lay of the land.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dashboard/&lt;/code&gt; — React 19, Vite, React Router v7, Zustand, Tailwind v4, Radix UI. &lt;strong&gt;Plain JavaScript (JSX)&lt;/strong&gt;. About 130 components, 28 tests, no &lt;code&gt;tsconfig&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;marketing/&lt;/code&gt; — Next.js 16, React 19. &lt;strong&gt;Full TypeScript&lt;/strong&gt;. About 30 pages, MDX blog, SEO-critical.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;backend/&lt;/code&gt; — Fastify, Node 20, Postgres. &lt;strong&gt;JavaScript with additive TypeScript&lt;/strong&gt; (utils and middleware migrated, routes still JS).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dashboard is what users see after they log in. Submissions inbox, form builder, settings, billing, analytics. Internal product surface. The marketing site is the public face: landing, pricing, docs, blog. The backend is the API both call.&lt;/p&gt;

&lt;p&gt;The case for TypeScript is strongest on the backend (security, types are contracts) and on the marketing site (long-lived content, multiple contributors potentially). The case is weakest on the dashboard, which is where I drew the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 1: I am one developer
&lt;/h2&gt;

&lt;p&gt;The hidden axis on the JavaScript-vs-TypeScript debate is &lt;strong&gt;team size&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;TypeScript's biggest payoff is on a team of more than three developers. When five people touch the same file in a month, types act as a contract between them. You change a function signature, the type errors light up everywhere it breaks, the team aligns.&lt;/p&gt;

&lt;p&gt;On a team of one, that contract is in my head. I changed the function. I know where it is used. The compiler running for 30 seconds to confirm what I already know is a tax I pay every save.&lt;/p&gt;

&lt;p&gt;This will change when I hire. I have an escape hatch (Reason 6) for that. Until then, the tax does not earn its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 2: Real shipping bugs are not type bugs
&lt;/h2&gt;

&lt;p&gt;I went through six months of bug reports and self-caught issues across the dashboard. I tagged each one with "TypeScript would have caught this" or "TypeScript would not have caught this".&lt;/p&gt;

&lt;p&gt;The breakdown was roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;8% — type bugs (passed wrong-shaped object, called undefined as a function, missed null)&lt;/li&gt;
&lt;li&gt;92% — everything else (wrong copy, broken API responses, race conditions, layout breaks, browser quirks, missing edge cases, regex errors, off-by-ones, CORS, CSP, hydration, state machine glitches)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TypeScript prevents the 8%. It does not touch the 92%. The 92% is what actually breaks production. Better tests, better error monitoring, and slowing down to think catch more bugs than the strictest &lt;code&gt;tsconfig.json&lt;/code&gt; ever has for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 3: The IDE already gives me 80% of TS
&lt;/h2&gt;

&lt;p&gt;Modern VS Code reads JSDoc comments and Radix UI's TypeScript definitions and gives me autocomplete on every prop I touch. I can hover over a component import and see its prop types. I can rename a function and have the language server find usages.&lt;/p&gt;

&lt;p&gt;I get this without paying for the build step, the strictness ratchet, or the type-import ceremony at the top of every file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * @param {{ form: { id: string, name: string }, onArchive: () =&amp;gt; void }} props
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;FormCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onArchive&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That JSDoc block above gives me autocomplete inside the component, lights up errors when I call &lt;code&gt;onArchive&lt;/code&gt; with arguments, and never blocks a &lt;code&gt;vite build&lt;/code&gt; from succeeding when I am trying to ship a fix at 11 PM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 4: My API is the source of truth
&lt;/h2&gt;

&lt;p&gt;The dashboard reads JSON from the backend. The shape of that JSON is defined in one place: the backend route handler. If I tighten the dashboard's expectations using TypeScript types, those types are duplicates of the backend's reality, and they will drift.&lt;/p&gt;

&lt;p&gt;Solving the drift problem usually means OpenAPI generators, tRPC, or shared package monorepos. Each of those is a separate complexity budget. The shortcut my dashboard uses: small defensive helpers around fetch, and &lt;code&gt;Optional Chaining&lt;/code&gt; everywhere I touch data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reading a submission&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;submission&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(no email)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submittedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submission&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That two-line pattern handles every shape-drift case I have hit so far. The cost is one &lt;code&gt;?.&lt;/code&gt; per access. The savings is not duplicating 200 backend types into the frontend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 5: Speed of iteration matters in year one
&lt;/h2&gt;

&lt;p&gt;The dashboard ships a new feature every week or two. Spam blocklist UI, bulk archive, tag filtering, hosted form pages, email reply, plan upgrade flow. Most of those features were specced and shipped within a few days each.&lt;/p&gt;

&lt;p&gt;The bottleneck on indie SaaS in year one is not "we ship code with bugs". It is "we are not shipping anything users want". Anything that slows my loop between "user reports a missing feature" and "user sees the missing feature on production" is taxed against my survival.&lt;/p&gt;

&lt;p&gt;TypeScript adds ceremony to that loop. Define the type. Pass the type through three layers of components. Re-run tsc when something five files away changed. Fight the never-have-I-ever-seen-this-error generic inference message. I have been through that loop on past projects and I know the cost.&lt;/p&gt;

&lt;p&gt;I would rather ship a small bug than block a fix on a refactor of types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 6: Migration is cheap when I am ready
&lt;/h2&gt;

&lt;p&gt;The decision to skip TypeScript on day one is reversible. Vite supports TypeScript out of the box. I can rename a file from &lt;code&gt;.jsx&lt;/code&gt; to &lt;code&gt;.tsx&lt;/code&gt;, add &lt;code&gt;// @ts-check&lt;/code&gt; to start with checking, and adopt strictness file by file. No big-bang rewrite. The exit ramp is paved.&lt;/p&gt;

&lt;p&gt;I have a written trigger for when I do it: &lt;strong&gt;second developer hired&lt;/strong&gt;, or &lt;strong&gt;dashboard exceeds 50,000 lines of code&lt;/strong&gt;, whichever comes first. At that point, the team contract benefit kicks in and the migration cost becomes worth paying.&lt;/p&gt;

&lt;p&gt;Until then, the option is preserved without paying for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest counter-arguments
&lt;/h2&gt;

&lt;p&gt;I want to address the ones I find genuinely strong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"TS catches refactor bugs."&lt;/strong&gt; True. I refactor the dashboard maybe once a quarter. The bugs it would have caught I caught via tests and manual QA. On a faster-refactoring codebase, this argument hits harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Library types are valuable."&lt;/strong&gt; True, and I get them anyway via my IDE. The TypeScript ecosystem produces type definitions whether or not my code is TypeScript. I read Radix UI's types in autocomplete without my project being TypeScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Onboarding new developers."&lt;/strong&gt; True. This is the strongest single argument, and it is the exact trigger I set for migration. Day one of a second developer is the day TypeScript starts earning its keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"It's not really that slow."&lt;/strong&gt; True at small scale, false at large scale. tsc on a thousand-file project is a real wait. Vite's &lt;code&gt;transpile-only&lt;/code&gt; mode hides it during dev, but CI still feels it. Worth noting.&lt;/p&gt;

&lt;p&gt;I am not arguing TypeScript is bad. I am arguing it has costs, and those costs were higher than the benefits for my specific situation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the marketing site is TypeScript
&lt;/h2&gt;

&lt;p&gt;For symmetry, here is why the marketing site got the opposite call.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;Next.js&lt;/strong&gt;, where TypeScript is the default. Going against the default would be its own tax. The marketing site is a &lt;strong&gt;content surface&lt;/strong&gt;, not a feature surface. It changes slowly, and changes that break it (broken links, missing meta tags) are caught by type checks. There are no API calls to argue about. The dynamic routes are typed against the file system. Cover images, slugs, frontmatter all benefit from types.&lt;/p&gt;

&lt;p&gt;Different code, different decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "no TypeScript" actually looks like
&lt;/h2&gt;

&lt;p&gt;For anyone curious about how a serious React app survives without TS, here is the load-bearing tooling.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSDoc on shared components and utility functions.&lt;/strong&gt; Not religiously, but on anything reused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PropTypes via Zustand store shapes.&lt;/strong&gt; The store's shape is documented at the top of each store file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Optional Chaining&lt;/code&gt; and &lt;code&gt;Nullish Coalescing&lt;/code&gt; everywhere.&lt;/strong&gt; Cheap insurance against backend drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small &lt;code&gt;api.js&lt;/code&gt; that wraps every fetch.&lt;/strong&gt; Centralized error handling, response unwrapping, retry logic. No type imports needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vitest tests for anything stateful.&lt;/strong&gt; Tests cover the surface that types would have covered, plus the surface they would not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ESLint with &lt;code&gt;eslint-plugin-react&lt;/code&gt;.&lt;/strong&gt; Catches the easy mistakes TypeScript brags about catching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The total tooling is not zero. It is just not TypeScript.&lt;/p&gt;

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

&lt;p&gt;If you are a solo developer shipping a SaaS dashboard, JavaScript is a defensible default. If you are on a team of three or more, TypeScript almost certainly pays for itself. If you are anywhere in between, look at your bug reports and ask which percentage is type bugs versus the other 92%.&lt;/p&gt;

&lt;p&gt;The default advice on dev.to is to reach for TypeScript automatically. I am not against that default. I am against not questioning it.&lt;/p&gt;

&lt;p&gt;The dashboard is in production, used daily by paying customers. The marketing site is TypeScript. The backend is migrating. Three codebases, three decisions, one indie hacker who tries to spend complexity where it actually pays off.&lt;/p&gt;

&lt;p&gt;What would change your mind on this tradeoff for your own SaaS?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Your contact form is the only page that touches money</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Mon, 18 May 2026 11:19:50 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/your-contact-form-is-the-only-page-that-touches-money-5ce9</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/your-contact-form-is-the-only-page-that-touches-money-5ce9</guid>
      <description>&lt;p&gt;In March 2025 a startup founder filled out my contact form to ask about a six-month consulting engagement. I never got the email. He moved on.&lt;/p&gt;

&lt;p&gt;Four months later I bumped into him on a different thread and he replied with "thought you weren't interested."&lt;/p&gt;

&lt;p&gt;I never figured out exactly what broke. SMTP credentials had rotated three weeks earlier and my Nodemailer wrapper was eating the auth error. My &lt;code&gt;/api/contact&lt;/code&gt; endpoint returned 200. My uptime monitor stayed green. My error tracker had nothing to log. Twenty-eight submissions vanished into the void before I noticed.&lt;/p&gt;

&lt;p&gt;That single missed submission was worth more than my AWS bill for the entire year.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;p&gt;Your contact form is the only page on your site that directly touches revenue. The rest is content. The form is a cash register, and if you wrote your own &lt;code&gt;/api/contact&lt;/code&gt; handler, there is a real chance it is leaking right now and you have no way to know.&lt;/p&gt;

&lt;p&gt;This post is about why that happens, what a real contact endpoint needs, and a 5-minute test you can run before lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why devs misclassify the form
&lt;/h2&gt;

&lt;p&gt;Most contact forms are written something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;site@mysite.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;me@mysite.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Contact from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;That code looks fine. It works on your machine. It works in staging. It works the first time you ship it.&lt;/p&gt;

&lt;p&gt;Then it sits untouched for years, because you treat it like another endpoint. Just another POST handler. Same priority as &lt;code&gt;/api/health&lt;/code&gt; or &lt;code&gt;/api/status&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is not the same. &lt;code&gt;/api/health&lt;/code&gt; failing wakes you up. &lt;code&gt;/api/contact&lt;/code&gt; failing is invisible. The user calling it does not refresh the page. They send the message, see the success animation, and assume you got it. Nobody DMs you on Twitter to say "hey, your contact form ate my message, you might want to check it."&lt;/p&gt;

&lt;p&gt;A broken &lt;code&gt;/api/contact&lt;/code&gt; is the worst kind of bug, because the only person who knows it broke is the person you most needed to hear from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent failure list
&lt;/h2&gt;

&lt;p&gt;Here is a partial inventory of ways my contact handler has actually broken in production across half a dozen projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SMTP credentials rotated by the email provider. The handler returns 200, the email never sends. No exception, because Nodemailer logs the auth failure to stderr by default and your serverless platform discards stderr&lt;/li&gt;
&lt;li&gt;Resend free tier hits 3,000 emails. Submissions 3,001 through whenever-you-notice silently drop with a quota error you never read&lt;/li&gt;
&lt;li&gt;A dependency upgrade changes how multipart/form-data parses. iPhone Safari submits return 415, every other device works fine, you only test on Chrome&lt;/li&gt;
&lt;li&gt;DNS MX record swap during an infra migration. Mail delivered straight to spam for 11 days before anyone checks the recipient inbox&lt;/li&gt;
&lt;li&gt;A scraping bot fires the endpoint 4,000 times overnight. Real submissions get buried under spam. You stop opening the inbox because it is mostly junk&lt;/li&gt;
&lt;li&gt;Vercel cold start times out the first submission of the morning. User retries, gives up after the second try&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these felt instantaneous when it broke. Each took me days or weeks to spot.&lt;/p&gt;

&lt;p&gt;The common thread: there is no error. Just an absence of an expected signal. And nobody monitors for absence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why error tracking cannot catch this
&lt;/h2&gt;

&lt;p&gt;Sentry catches exceptions. A silent 200 with a missing email is not an exception. The handler did its job, by the strictest reading of the code. It returned a status code. The bug is what your handler did not do, and the absence of an action is invisible to a stack trace.&lt;/p&gt;

&lt;p&gt;Your uptime monitor catches downtime. The endpoint responds 200, the page loads, the dashboard stays green. Green dashboard, broken revenue.&lt;/p&gt;

&lt;p&gt;The only signal that exists is a real human sending a real message and noticing nothing came back. That signal is one customer follow-up away from you noticing. Which means you only notice when the customer cares enough to follow up. Most do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a production contact endpoint actually needs
&lt;/h2&gt;

&lt;p&gt;I wrote this list on a napkin in 2024 after another silent failure. It was embarrassing how short it was, and how much of it my homegrown handler did not have.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A delivery receipt.&lt;/strong&gt; Did the email actually leave the server? Not "did the SMTP transaction return 200", but "did the message hit the recipient mailbox". Without this you are flying blind.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A dashboard showing every submission.&lt;/strong&gt; Regardless of whether the email arrived. The submission and the notification email are two separate concerns. Treating them as one is how silent failures happen.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Spam protection that does not show CAPTCHA.&lt;/strong&gt; Honeypot fields, timing checks, and rate limits handle 95% of bot traffic without ever interrupting a human. CAPTCHA on a contact form kills conversion. Do not ship it unless you have run out of other options.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Per-IP rate limiting on the endpoint.&lt;/strong&gt; Bots flood. Without this, your inbox becomes useless and your real submissions get triaged into the trash by your own pattern-matching.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Notification redundancy.&lt;/strong&gt; Email plus Slack, or email plus Telegram. If one channel breaks, the other still pings you. I learned this the hard way.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audit log with timestamps, IP, and user agent.&lt;/strong&gt; When something looks fishy (a submission that mentions a feature you do not ship, or a contact at 4 AM their local time), you want the metadata. When something looks lost, you want a record that proves the submission existed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Replay capability.&lt;/strong&gt; When a notification email goes missing, you should be able to forward it to yourself or to the right teammate from a dashboard. Not by writing a SQL query.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auto-responder for the submitter.&lt;/strong&gt; A short "we got your message, here is what happens next" email. Proves to the customer that the form worked, which means if they do not hear back from you they will follow up instead of assuming you ghosted them.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can build all 8 yourself. I have. It is somewhere between 40 and 80 hours of work, depending on how careful you are about edge cases. Then you maintain it for the life of the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build vs buy math
&lt;/h2&gt;

&lt;p&gt;Here is the math I run every time someone asks why I do not just write my own:&lt;/p&gt;

&lt;p&gt;Initial build: 40 to 80 hours, depending on how thorough you are&lt;br&gt;
Ongoing maintenance: 10 to 20 hours a year for dependency upgrades, infra changes, and email provider migrations&lt;br&gt;
Hidden cost: every silent failure costs you the value of a missed inbound lead, and you cannot measure this until after it happens&lt;/p&gt;

&lt;p&gt;Against that:&lt;/p&gt;

&lt;p&gt;Free tier of any decent form service: $0, up to roughly 50 submissions a month&lt;br&gt;
Paid tier: $5 to $15 a month, unlimited&lt;br&gt;
Time to first working submission: under five minutes&lt;/p&gt;

&lt;p&gt;I have spent days arguing with engineers who insist they can do it in an afternoon. They are right, they can. The first time. The cost is not the first time. The cost is years 2 through 5 of every project they ship, multiplied by the fact that they will never spot the silent failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack choice
&lt;/h2&gt;

&lt;p&gt;There is no single right answer. The right hosted form service depends on your stack and your budget.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Formspree has been around since 2017, well-tested, decent free tier&lt;/li&gt;
&lt;li&gt;Basin is the same shape with simpler pricing&lt;/li&gt;
&lt;li&gt;Web3Forms is the cheapest option I know if you just need an inbox&lt;/li&gt;
&lt;li&gt;Getform has the best file upload support I have seen&lt;/li&gt;
&lt;li&gt;FormTo is the one I built (formto.dev), because I wanted self-host plus custom SMTP plus a dashboard I actually wanted to open every morning&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The brand matters less than the fact that you stop trusting your own &lt;code&gt;/api/contact&lt;/code&gt; and start trusting a service whose only job is to not lose your submissions. That single change moves the failure mode from "invisible" to "someone else's dashboard with a status indicator."&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to use a hosted form service
&lt;/h2&gt;

&lt;p&gt;Three real cases where rolling your own makes sense:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You have hard data residency requirements.&lt;/strong&gt; If your industry forbids submission data crossing into US-based SaaS, you either self-host an open-source option (FormTo has a self-host build, Formspree does not) or you build your own. The decision then becomes self-host vs DIY, and self-host still wins on time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your form is one input to a complex pipeline.&lt;/strong&gt; If submissions trigger a workflow that touches your auth, your billing, your CRM, and your internal Slack in real time, a hosted form adds a hop you have to coordinate. At that point your form is part of your product, not a marketing surface, and you should treat it like product code with the same rigor as your billing path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You are at zero visitors and learning.&lt;/strong&gt; If you are building your first SaaS and the contact form sees three submissions a year, the failure cost is small enough that the learning value of building it yourself wins. Build it badly, watch it break, then switch to a hosted service the day you actually start caring about leads.&lt;/p&gt;

&lt;p&gt;If none of those describe you, the math is not close. Use a hosted service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5-minute test you should run right now
&lt;/h2&gt;

&lt;p&gt;Stop reading and do this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open your live site in an incognito window&lt;/li&gt;
&lt;li&gt;Fill out your contact form with a Gmail address you do not normally check&lt;/li&gt;
&lt;li&gt;Submit it&lt;/li&gt;
&lt;li&gt;Open the Gmail inbox&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now verify four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the email arrive at all?&lt;/li&gt;
&lt;li&gt;Did it land in inbox, not spam?&lt;/li&gt;
&lt;li&gt;Did it arrive in under 60 seconds?&lt;/li&gt;
&lt;li&gt;Is the from-address sane, or does it look like a default &lt;code&gt;noreply&lt;/code&gt; you forgot to configure?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you cannot confidently say yes to all four, your form is leaking. Maybe a little, maybe a lot. You will not know until you look.&lt;/p&gt;

&lt;p&gt;I run this test on every project I own once a quarter. It takes five minutes. It has surfaced two silent failures so far this year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to the founder from March 2025
&lt;/h2&gt;

&lt;p&gt;He never came back. I built him a perfectly normal contact form in 30 minutes one weekend in 2021 and assumed it would keep working. It did, until it did not, and then it lied to me about whether it was working.&lt;/p&gt;

&lt;p&gt;The cost of a working contact form is between $0 and $15 a month. The cost of a broken contact form is every inbound lead you miss until the day a customer pings you on Twitter to ask why you ghosted them. Those numbers are not close.&lt;/p&gt;

&lt;p&gt;Treat the form like the cash register it is. Use a service. Run the test.&lt;/p&gt;

&lt;p&gt;When was the last time you tested your contact form in production, not in your dev environment? Be honest.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>indiehackers</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Per-Section Briefs: How to Stop AI Agents Losing the Plot at 2000 Words</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Sun, 17 May 2026 16:35:26 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/per-section-briefs-how-to-stop-ai-agents-losing-the-plot-at-2000-words-431o</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/per-section-briefs-how-to-stop-ai-agents-losing-the-plot-at-2000-words-431o</guid>
      <description>&lt;p&gt;In March 2025 I wired up my first long-form content agent. One prompt. A 2000 word target. A list of seven H2s pasted in at the top. The first 600 words read fine. By word 1200 the model had quietly forgotten the original thesis. By word 1800 it was paraphrasing the introduction back at me, just with worse vocabulary.&lt;/p&gt;

&lt;p&gt;That article never shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;p&gt;Instead of asking an LLM to write a 2000 word article in one shot, give each H2 section its own brief, generate the sections in parallel, then stitch the result together. The model only has to hold one section at a time. You can regenerate any failing section without burning the entire piece.&lt;/p&gt;

&lt;p&gt;I have shipped over 50,000 long-form articles through this pattern in production for Articfly. Every time I have tried to skip a step and collapse back to a single prompt, output quality dropped. Here is the full pattern and the failure modes it actually solves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why single-prompt long-form fails
&lt;/h2&gt;

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

&lt;p&gt;What goes wrong when you push past 1000 to 1500 words in a single completion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model loses recency. Anything in the first 30 percent of the system prompt drifts out of working attention.&lt;/li&gt;
&lt;li&gt;Internal repetition explodes. Without a section-level scope, the model rehashes earlier points to fill the word budget.&lt;/li&gt;
&lt;li&gt;Transitions degrade. The model strings paragraphs together with generic connectors because it has no scoped goal for the current section.&lt;/li&gt;
&lt;li&gt;Errors compound. If section three is weak, section four often inherits the same weakness because the model treats its own earlier text as ground truth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a model-size problem. I tested the same single-prompt setup with GPT-4o, Claude Sonnet, and Gemini 1.5 Pro. All three drifted past the 1200 word mark. The mechanism is structural, not capability based. You can throw more parameters at it and the failure mode moves from word 1200 to word 1500. It does not go away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

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

&lt;p&gt;Three moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An outline pass that produces a list of H2 sections, each with a target claim, supporting evidence, and a one-line transition&lt;/li&gt;
&lt;li&gt;A section pass that writes one H2 at a time using only the brief and the article-level voice profile&lt;/li&gt;
&lt;li&gt;A stitch pass that joins the sections, fixes paragraph-level transitions, and runs a final consistency check&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three calls per article, not one. Sometimes four if you add a fact-check loop. The token cost is only about 50 percent higher than the single-prompt approach because each call is shorter and tighter. You pay for the structure, not for raw token volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: The outline
&lt;/h2&gt;

&lt;p&gt;The outline is the most important call in the pipeline. Get this right and the rest is mechanical.&lt;/p&gt;

&lt;p&gt;Outline prompt template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Topic: &amp;lt;topic&amp;gt;
Primary keyword: &amp;lt;keyword&amp;gt;
Target length: 2000 words
Voice profile: &amp;lt;attached&amp;gt;

Produce an outline with 6 to 8 H2 sections.
For each section provide:
- title (under 60 chars)
- claim (one sentence, what this section argues)
- evidence (2 to 3 bullets, what supports the claim)
- transition (one sentence, how this section leads into the next)
- target_words (integer, must sum to 1800 to 2100)

Output strict JSON.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key constraints are the claim per section and the target_words sum. Without a per-section claim, the model invents one mid-write. Without a word budget, sections drift to 400 words each and the article overshoots to 3200.&lt;/p&gt;

&lt;p&gt;Use a strict JSON output and validate it with Zod or Pydantic. Reject and retry if the word sum is off by more than 10 percent, or if any section is missing a claim. I do not retry the outline silently. If it fails validation twice in a row, the pipeline hard-fails and I see it in logs. A bad outline poisons everything downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Write the sections
&lt;/h2&gt;

&lt;p&gt;For each H2 in the outline, fire a section call. These can run in parallel because they do not depend on each other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;js&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;voiceProfile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildSectionPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;voiceProfile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;writeSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;voiceProfile&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 section prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are writing H2 section "&amp;lt;title&amp;gt;" of a longer article on &amp;lt;topic&amp;gt;.
The full article will be 2000 words. This section targets &amp;lt;target_words&amp;gt; words.

Section claim: &amp;lt;claim&amp;gt;
Supporting evidence: &amp;lt;evidence&amp;gt;
Transition out: &amp;lt;transition&amp;gt;

Voice profile:
&amp;lt;voice&amp;gt;

Constraints:
- Do not restate the article introduction
- Do not preview future sections
- Open with the claim or a concrete example, not a generic setup line
- End with the transition or a sentence that sets it up
- Use first person if the voice profile uses first person
- No headers inside the section (the H2 is handled at stitch time)

Write the section now.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "do not restate" and "do not preview" lines are load-bearing. Without them, every section starts with "In this article we will explore" and ends with "Next we will look at". The model wants to be helpful in a way that breaks long-form structure. You have to say no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Stitch
&lt;/h2&gt;

&lt;p&gt;The stitch pass is short. It takes all sections, joins them, and runs one more LLM call to fix paragraph-level transitions and remove duplicate phrases.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;js&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stitch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;joined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="s2"&gt;`## &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Fix paragraph transitions between sections.
      Do not rewrite. Do not change facts.
      Remove duplicate phrases that appear across sections.
      Replace generic connector words with concrete prose.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Low temperature here on purpose. The stitch call is an editor, not a writer. If you let it ride at 0.7 it will start adding new content and the article inflates past target length.&lt;/p&gt;

&lt;h2&gt;
  
  
  The voice profile
&lt;/h2&gt;

&lt;p&gt;The voice profile is what stops per-section calls from sounding generic. I generate it once per customer by scraping 10 to 20 of their published articles and extracting patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Average sentence length&lt;/li&gt;
&lt;li&gt;Vocabulary level (Flesch reading ease)&lt;/li&gt;
&lt;li&gt;First person versus third person&lt;/li&gt;
&lt;li&gt;Common rhetorical moves (anecdote opener, contrarian claim, structured list, war story)&lt;/li&gt;
&lt;li&gt;Banned words specific to the brand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The voice profile gets injected into every section call. Without it, each section sounds like a different writer wrote it. The stitch pass cannot fix that drift, only prevent it upstream. You cannot edit voice into existence at the end.&lt;/p&gt;

&lt;p&gt;Voice extraction warrants its own article. I will not cover the extraction prompt here, only flag that it is required for any pipeline that wants outputs to read like one writer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips that matter in production
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Cache the outline. If the user regenerates one section, do not redo the outline. Hash the topic plus keyword plus voice profile and reuse the cached outline.&lt;/li&gt;
&lt;li&gt;Run section generations in parallel. Latency drops from 90 seconds (sequential) to under 30 (parallel).&lt;/li&gt;
&lt;li&gt;Validate section word counts. If a section comes back at 50 percent of its target, regenerate only that section.&lt;/li&gt;
&lt;li&gt;Keep the stitch model the same as the section model. Mixing models at stitch time changes the voice subtly and readers notice.&lt;/li&gt;
&lt;li&gt;Log each section call separately. When a customer complains about an article, you can trace exactly which section drifted and why.&lt;/li&gt;
&lt;li&gt;Token budget the section call hard. If you leave max_tokens unbounded, the model fills the context with one runaway section.&lt;/li&gt;
&lt;li&gt;Strip generic closers like "in summary" with a regex after stitch. The model still inserts them about 15 percent of the time even when told not to.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When NOT to use this pattern
&lt;/h2&gt;

&lt;p&gt;Per-section briefs add real complexity. Three LLM calls instead of one. Outline validation logic. Section retry logic. Stitch pass. Section storage so you can recover from partial failure. For some content types this is wasted engineering.&lt;/p&gt;

&lt;p&gt;Skip this pattern when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are generating under 800 words. Single-prompt handles short-form fine.&lt;/li&gt;
&lt;li&gt;The content is templated (job posts, product descriptions, FAQ entries). A template plus variable injection beats both single-prompt and per-section.&lt;/li&gt;
&lt;li&gt;You need sub-five-second latency. Three sequential calls plus stitch will not hit that, even with parallelization in the middle step.&lt;/li&gt;
&lt;li&gt;A human editor reviews every output anyway. The marginal quality gain does not survive a full editor pass.&lt;/li&gt;
&lt;li&gt;The output is low-quality-bar SEO filler. Per-section briefs do not save bad input topics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I run a separate pipeline for short-form (under 600 words) that is one tight call with a tight brief. The cost of the agentic pattern is not free. Treating every output the same is its own kind of one-size-fits-all problem.&lt;/p&gt;

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

&lt;p&gt;For anyone wiring this up from scratch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Outline pass: any frontier model works. I use Claude Sonnet because the structured JSON output is reliable across temperature settings&lt;/li&gt;
&lt;li&gt;Section pass: parallel calls, Claude Sonnet or GPT-4o, temperature 0.6 to 0.8&lt;/li&gt;
&lt;li&gt;Stitch pass: same model as the section pass, temperature 0.2 to 0.3&lt;/li&gt;
&lt;li&gt;Validation: Zod (TypeScript) or Pydantic (Python) on the outline JSON&lt;/li&gt;
&lt;li&gt;Retry policy: 2 attempts per section with exponential backoff. Fail-fast on the outline pass with no retry, just hard fail&lt;/li&gt;
&lt;li&gt;Storage: I write each section to Postgres as it completes. If the stitch step fails, I can resume without re-running section calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total cost per 2000 word article runs about $0.04 to $0.08 depending on model. The single-prompt version cost $0.03 to $0.05. The per-section pattern costs roughly 50 percent more in tokens. The quality difference shipped 50,000 articles. I will eat the 50 percent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The article that drifted back in March 2025 did eventually ship. After I rewrote the pipeline around per-section briefs, I ran the same topic through. Same target length. Same H2 list. Same voice profile. The result read like one writer wrote it from start to finish.&lt;/p&gt;

&lt;p&gt;The single-prompt version is still in my git history. About once a quarter I dig it up to remind myself why three calls beat one.&lt;/p&gt;

&lt;p&gt;What pattern do you use to keep long-form AI output coherent past the 1000 word mark?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>40 hours I wasted before I built my own form backend</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Sun, 17 May 2026 11:59:48 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/40-hours-i-wasted-before-i-built-my-own-form-backend-5bh8</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/40-hours-i-wasted-before-i-built-my-own-form-backend-5bh8</guid>
      <description>&lt;p&gt;Last Friday night I opened my GitHub and ran a search across every personal repo. The query: anything mentioning "form", "submission", "contact", or "POST /". Forty seven results came back. Most of them were variations of the same file.&lt;/p&gt;

&lt;p&gt;I sat there counting. Twelve different projects. Five different stacks (PHP, Express, Hapi, FastAPI, Next API routes). Each one had a handler that did roughly the same things. Receive POST. Validate. Check honeypot. Save somewhere. Send email.&lt;/p&gt;

&lt;p&gt;I tried to estimate how long each one took. Three hours minimum for the simple ones. Six or seven for the ones that needed Slack notifications or file uploads. Add up the dozen, factor in the bugs I caught later, count the rate limit code I copy pasted from Stack Overflow each time.&lt;/p&gt;

&lt;p&gt;The number came out to forty hours. A full work week, spent solving the same problem over and over, across six years.&lt;/p&gt;

&lt;p&gt;That night I started building &lt;a href="https://formto.dev" rel="noopener noreferrer"&gt;FormTo&lt;/a&gt;, the tool I should have written in 2019. This post is the story of why it took me so long, and the things I wish someone had said to me back then.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;If you have built a contact form for more than one project, you have probably already lost more hours than you think. The handler feels small every time. The handler is not the problem. The hidden tax around the handler (spam, deliverability, notifications, exports, edge cases) is what eats your weekends. Building once and reusing is cheaper, even if you have to pay nine bucks a month or self host something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first handler (2018)
&lt;/h2&gt;

&lt;p&gt;A friend asked me to build a small site for his consulting practice. Plain HTML, a couple of pages, a contact form. I quoted him five hundred euros. We agreed on a deadline.&lt;/p&gt;

&lt;p&gt;The HTML and CSS took two evenings. The contact form took three. I wrote a PHP script that mailed me whenever someone submitted. It worked. He paid me. I went on with my life.&lt;/p&gt;

&lt;p&gt;Two weeks later he called. The form was getting spam. Like a lot of spam. I added a honeypot field, redeployed, and went back to bed. Three weeks after that he called again. Email delivery from his shared hosting was failing for half the recipients. I migrated him to Mailgun, added DKIM and SPF records to his DNS, billed him for the extra work, and felt smart.&lt;/p&gt;

&lt;p&gt;I had no idea that this small contact form would become a pattern I would repeat for every freelance project for the next six years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exact same thing, twelve times
&lt;/h2&gt;

&lt;p&gt;In 2019 I wrote an Express handler for a client whose stack was Node. Same five steps, slightly different syntax. Mailgun for email again.&lt;/p&gt;

&lt;p&gt;In 2020 I wrote a Hapi handler for a startup that hired me for two months. Their dev lead insisted on Hapi, which I had never used. I learned just enough Hapi to write the form handler, then forgot all of it.&lt;/p&gt;

&lt;p&gt;In 2021 I wrote a FastAPI handler for my own SaaS, the one before this one. I added a small queue because submissions were spiky.&lt;/p&gt;

&lt;p&gt;In 2022 I wrote two more Next API route handlers. By this point I had a private gist with my "starter snippet" that I copy pasted in. The snippet was 180 lines.&lt;/p&gt;

&lt;p&gt;In 2023 and 2024 I wrote four more. The snippet had grown to 240 lines. I had added blocklist support, file upload validation, and a hacky retry loop for failed email sends.&lt;/p&gt;

&lt;p&gt;Each time, the first hour of work was "ok, what was that thing I did last time". Each time, the next hour was "wait, my snippet does not handle this new edge case". Each time, the third hour was a fresh round of testing against Postman.&lt;/p&gt;

&lt;p&gt;Three hours, twelve times. Plus rework, plus the spam incident in 2020, plus the deliverability mess in 2022, plus the time I shipped a regex for email validation that rejected anything with a plus sign in the address.&lt;/p&gt;

&lt;p&gt;Forty hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The night I broke
&lt;/h2&gt;

&lt;p&gt;Last summer I deployed a small admin form for a client at 11 PM on a Friday. I was tired. I skipped the honeypot field "because the form is behind auth anyway". My wife came in to ask when I would be in bed. I said twenty minutes.&lt;/p&gt;

&lt;p&gt;At 2 AM I was still working. The form was open behind auth, but auth let unauthenticated POSTs through because of a misconfigured middleware. A bot found the endpoint within an hour of deploy and submitted four thousand fake leads. Most of them triggered the email notification to my client. His inbox was destroyed.&lt;/p&gt;

&lt;p&gt;Worse: one of the four thousand was a real lead. A real customer had used the same form to ask about a contract. The real email was buried in the spam, my client missed it, the deal went somewhere else.&lt;/p&gt;

&lt;p&gt;I patched the middleware, added a honeypot at 3 AM, refunded my client for the missed deal, and went to bed angry. Not at my client. At myself, for shipping the same broken pattern for the seventh year in a row.&lt;/p&gt;

&lt;p&gt;Two weeks later I started counting the hours. That was when I decided to build the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;I built FormTo over four months of evenings. The act of writing it as a product, instead of a snippet, forced me to think about the things I had been sweeping under the rug for six years.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honeypots beat CAPTCHA most of the time
&lt;/h3&gt;

&lt;p&gt;Every project I shipped had a CAPTCHA on the form by default. Most of those forms did not need one. A hidden field with a bait name (something like &lt;code&gt;website&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, or &lt;code&gt;phone_number&lt;/code&gt;) catches almost every bot, costs zero user experience, and works without JavaScript.&lt;/p&gt;

&lt;p&gt;I now check for nineteen common bait names automatically. CAPTCHA is the last resort, not the first move. Most of the spam I used to get was solved by removing CAPTCHA and adding honeypots.&lt;/p&gt;

&lt;h3&gt;
  
  
  Email delivery is its own job
&lt;/h3&gt;

&lt;p&gt;I spent more time over the years debugging email delivery than I did writing form handlers. Shared hosting SMTP. DKIM set up but not signing. SPF too loose. Hosts whose IPs landed on a blocklist between Tuesday and Thursday.&lt;/p&gt;

&lt;p&gt;The pattern I settled on: a default Resend integration that works out of the box, plus the option to plug in your own SMTP credentials so emails ship from your own domain. Your domain gets the reputation. Your inbox gets the replies. The host does not see the email content.&lt;/p&gt;

&lt;h3&gt;
  
  
  Notifications matter more than the dashboard
&lt;/h3&gt;

&lt;p&gt;For six years I assumed people would log in to a dashboard to see new submissions. They do not. They check email for ten minutes, then they tune it out. The signal that actually gets read is Slack, Discord, or Telegram. Especially Telegram, which I underestimated for years.&lt;/p&gt;

&lt;p&gt;Per form notification toggles, with the credentials saved at the user level. One Telegram bot for all your forms. The dashboard exists, but most people barely open it after the first week.&lt;/p&gt;

&lt;h3&gt;
  
  
  File uploads are a tax
&lt;/h3&gt;

&lt;p&gt;Every form tool I have ever used either pretends file uploads do not exist or adds them as an enterprise feature. I added them as part of the paid plans. The implementation is small: multipart parsing, MIME validation, a size limit per tier. The reason I had skipped this for six years across freelance projects was not that it was hard. It was that I always told myself "the client will not need this" and then they always did.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self hosting is an option, not a headline
&lt;/h3&gt;

&lt;p&gt;I open sourced the backend under AGPL 3.0. Docker Compose, Caddy, PostgreSQL. No telemetry. People who want to run it themselves can. The vast majority do not want to, and that is fine. The SaaS pays for the maintenance, the self host option keeps me honest. Both versions read from the same codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the snippet is still the right answer
&lt;/h2&gt;

&lt;p&gt;This post would be useless if I did not say where the trade goes the other way.&lt;/p&gt;

&lt;p&gt;Use a form backend (mine or anyone else's) when the form is a contact form, a feedback form, a waitlist signup, or any case where the submission lives on its own and just needs to land somewhere visible.&lt;/p&gt;

&lt;p&gt;Write your own handler when the form is part of a larger workflow with conditional logic per step, when the submission needs to write across half a dozen tables, or when you are already running a backend with fifty endpoints and the form is just one more.&lt;/p&gt;

&lt;p&gt;I still write custom handlers. I just do not write them for contact forms anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The math, redone
&lt;/h2&gt;

&lt;p&gt;This morning I ran the same GitHub search I ran on that Friday night. Same query. Same repos. I filtered out anything from before FormTo shipped.&lt;/p&gt;

&lt;p&gt;Six projects shipped after I started using my own tool. Total time spent on form handling across those six projects: about forty minutes. Most of that was pasting the action URL and writing the autoresponder template.&lt;/p&gt;

&lt;p&gt;Forty hours over six years, replaced with forty minutes over one year. That ratio is the entire reason this tool exists.&lt;/p&gt;

&lt;p&gt;How many hours have you spent writing the same form handler? I am genuinely curious. Mine was forty. I have a feeling yours is higher than you want to admit.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>indiehackers</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I got tired of paying for form backends, so I built my own</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Wed, 08 Apr 2026 09:57:47 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/i-got-tired-of-paying-for-form-backends-so-i-built-my-own-477f</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/i-got-tired-of-paying-for-form-backends-so-i-built-my-own-477f</guid>
      <description>&lt;p&gt;A buddy of mine does freelance web dev. Mostly small business sites -- restaurants, dentists, that kind of thing. Every single client needs a contact form. And every single time it's the same conversation: &lt;em&gt;"Why am I paying $20 a month just to get emails from my website?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fair question honestly.&lt;/p&gt;

&lt;p&gt;His solution was to rig up an n8n workflow that catches form submissions via webhook and dumps them into a Google Sheet. It works. Technically. Until a column shifts, or the webhook URL expires, or the client wants to see their submissions somewhere that isn't a spreadsheet.&lt;/p&gt;

&lt;p&gt;I kept thinking about this. A form backend is such a basic thing. You receive a POST request. You save it. You send a notification. Maybe you show it in a dashboard. That's it. That's literally the whole product. And yet Formspree wants $20/mo for it. Basin, Formcarry -- same ballpark.&lt;/p&gt;

&lt;p&gt;So around December last year I decided to just build the thing myself.&lt;/p&gt;




&lt;h2&gt;
  
  
  The building process
&lt;/h2&gt;

&lt;p&gt;I should mention -- this was always a side project. I run a small automation agency during the day, so FormTo got evenings and weekends. Some weeks I'd grind on it for hours, other weeks I barely touched it. That's just how side projects go I guess.&lt;/p&gt;

&lt;p&gt;The backend came together pretty fast. Fastify, PostgreSQL, standard stuff. I used Claude Code a lot during development and honestly it was a game changer for velocity. Stuff that would normally take me a full evening to figure out -- like getting JWT auth right, or writing all the database migrations, or setting up the Caddy reverse proxy config -- I could knock out in an hour or two. Not saying AI wrote the whole thing, but it definitely cut the timeline by half, maybe more. For a solo dev working on nights and weekends that matters a lot.&lt;/p&gt;

&lt;p&gt;The frontend though. That's where things got interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Upwork disaster
&lt;/h2&gt;

&lt;p&gt;I'm primarily a backend guy. I can hack together a React app but I'm not going to pretend I'm a frontend specialist. So I thought OK, let me hire someone on Upwork to build a clean dashboard UI. Found a guy, decent portfolio, good reviews. Gave him the designs, explained the API, set up the repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a disaster.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first delivery was basically a template he found somewhere with my API calls shoved in. Half the components didn't work. State management was all over the place. I spent more time reviewing and fixing his code than it would have taken me to write it from scratch. After two rounds of revisions that somehow made things worse, I just paid him for the hours and moved on.&lt;/p&gt;

&lt;p&gt;Ended up building the frontend myself with Claude Code again. Took me about two weeks to get something I was actually happy with. React 19, Tailwind v4, Radix UI for the component primitives. Is it the most beautiful dashboard ever? No. But it works, it's consistent, and I understand every line of code in it. That Upwork detour cost me about a month of progress and a few hundred dollars for code I threw away entirely.&lt;/p&gt;

&lt;p&gt;Lesson learned: if your project is small enough and you're technical enough, just build it yourself. Hiring makes sense when you're scaling, not when you're prototyping.&lt;/p&gt;




&lt;h2&gt;
  
  
  So what is FormTo actually?
&lt;/h2&gt;

&lt;p&gt;FormTo is a self-hosted form backend. You take any HTML form, point the &lt;code&gt;action&lt;/code&gt; attribute at your FormTo instance, and submissions start showing up in a dashboard. No JavaScript SDK, no API keys, no build step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"https://forms.yourdomain.com/f/contact-abc123"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I only built stuff I actually needed or my friend kept asking for. Notifications go to email (any SMTP), Telegram, Slack, or a generic webhook -- so you can still pipe it to n8n or Make if that's your thing, but you don't have to anymore. There's spam handling with honeypot fields and rate limiting instead of CAPTCHAs because nobody likes CAPTCHAs. Auto-close after X submissions or a date, which turned out to be really handy for RSVPs and waitlists. CSV export because someone will always ask for a spreadsheet, it's like a law of nature at this point. And hosted form pages if you don't even want to write HTML -- just share a link and you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;The whole thing runs on Docker Compose. One command and you've got a working form backend with automatic HTTPS via Caddy. There's a first-run wizard so you don't need to mess with config files beyond setting a domain and database password. It runs fine on a &lt;strong&gt;$5 VPS&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/lumizone/formto
&lt;span class="nb"&gt;cd &lt;/span&gt;formto
&lt;span class="nb"&gt;cp &lt;/span&gt;formto.env.example formto.env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing I want to be clear about -- this is &lt;strong&gt;not&lt;/strong&gt; a form builder. No drag-and-drop, no visual editor, no templates. You write your form however you want and point it at FormTo. That's intentional. There are a million form builders out there already. I just wanted the backend part done well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source + cloud
&lt;/h2&gt;

&lt;p&gt;It's open source under AGPL-3.0. The full feature set is right there, nothing held back behind a paywall or a pro tier. I'm also working on a cloud version at &lt;a href="https://formto.dev" rel="noopener noreferrer"&gt;formto.dev&lt;/a&gt; for people who don't want to deal with servers, but the self-hosted version will always be the complete product. No artificial limits to nudge you toward paying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/lumizone/formto" rel="noopener noreferrer"&gt;github.com/lumizone/formto&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's still early and I'm the only one working on it. If something breaks, if you want a feature, or if you just want to tell me the UI is ugly -- open an issue. I read all of them.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>webdev</category>
      <category>selfhosted</category>
      <category>docker</category>
    </item>
    <item>
      <title>How AI Can Finally Write B2B Outreach Emails That Actually Work</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Sun, 07 Sep 2025 17:40:30 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/how-ai-can-finally-write-b2b-outreach-emails-that-actually-work-13nm</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/how-ai-can-finally-write-b2b-outreach-emails-that-actually-work-13nm</guid>
      <description>&lt;p&gt;Cold emailing has always been a strange mix of art and science. On one hand, it’s about persuasion, psychology, and human connection. On the other, it’s a numbers game — you need enough volume to cut through the noise and land in front of the right people.&lt;/p&gt;

&lt;p&gt;For years, founders, marketers, and sales reps have been caught in the same dilemma: personalize every email manually and burn endless hours, or send generic templates at scale and watch reply rates crash to zero.&lt;/p&gt;

&lt;p&gt;When AI burst onto the scene, many people thought the problem was solved. &lt;strong&gt;“Just let ChatGPT write your emails!”&lt;/strong&gt; But anyone who tried quickly discovered the ugly truth: most AI-generated emails sound painfully robotic, overloaded with buzzwords, or so vague they could apply to any business on the planet.&lt;/p&gt;

&lt;p&gt;So why does AI stumble so badly at something as simple as writing a cold email? And more importantly, how do we fix it?&lt;/p&gt;




&lt;h2&gt;
  
  
  Why AI Struggles With Email Outreach
&lt;/h2&gt;

&lt;p&gt;AI’s weakness comes down to &lt;strong&gt;context&lt;/strong&gt;. Large language models are designed to be general-purpose — they can write code, explain history, summarize legal documents, and yes, write emails. But that flexibility comes at a cost.&lt;/p&gt;

&lt;p&gt;Think of it like buying a single machine that tries to function as a fridge, oven, washing machine, coffee maker, and television all at once. Technically, it can do all of those things. But if you want the perfect sourdough baked at 230°C? Or a rich espresso brewed at 8 bars of pressure? That multi-tool monster won’t cut it.&lt;/p&gt;

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

&lt;p&gt;Email outreach requires precision. You need the right tone, the right length, the right level of personalization, and a clear value proposition. It’s not enough for the text to “look okay” — it has to feel like it came from a human who understands the recipient’s problems.&lt;/p&gt;

&lt;p&gt;When you ask AI:&lt;br&gt;
“Write me a cold email to a marketing agency.”&lt;/p&gt;

&lt;p&gt;It fills in the blanks with generic business language. You get lines like:&lt;br&gt;
“We offer innovative solutions to help your business thrive in today’s competitive landscape.”&lt;/p&gt;

&lt;p&gt;It looks polished. But it’s the kind of sentence that 10,000 other emails also contain. And in the real world, that means instant delete.&lt;/p&gt;




&lt;h2&gt;
  
  
  Structure: Giving AI a Map to Follow
&lt;/h2&gt;

&lt;p&gt;Here’s the first key: AI performs best when you don’t just give it a vague request, but a &lt;strong&gt;map&lt;/strong&gt;. Structure is the difference between usable output and robotic nonsense.&lt;/p&gt;

&lt;p&gt;Think of sending instructions to AI like shipping a package:&lt;br&gt;
If you throw your product, address, and label separately into the sky, maybe — by pure chance — it lands in the right place.&lt;br&gt;
But if you pack it carefully, seal the box, and give it to a courier with clear delivery instructions, you know it will arrive exactly where it should.&lt;/p&gt;

&lt;p&gt;AI is the same. If you tell it “write me a cold email,” it’s guessing. But if you hand it a &lt;strong&gt;proven structure&lt;/strong&gt; and &lt;strong&gt;specific guidelines&lt;/strong&gt; — suddenly, it becomes a precision tool.&lt;/p&gt;

&lt;p&gt;Here’s an example skeleton I use in real campaigns:&lt;br&gt;
{{name}},&lt;/p&gt;

&lt;p&gt;{{icebreaker}}&lt;br&gt;
Everyone in [[niche]] is leaking cash, slow intake, lost follow-up, clunky admin., all bottlenecks AI actually kills. No fancy buzzwords. This is about running tighter, faster, and never missing a chance to win deals or lock in repeat buyers.&lt;br&gt;
{{name}}, spotted gaps you can close for good. Most never even spot the leaks. Want the straight playbook or staying stuck? Reply yes or no, nothing else.&lt;br&gt;
[[YourName]]&lt;/p&gt;

&lt;p&gt;This structure forces clarity. It starts with a hook (the icebreaker), names specific problems the recipient actually feels, and ends with a simple binary CTA. No long-winded pitch, no corporate jargon, no desperate begging for attention.&lt;/p&gt;

&lt;p&gt;When you feed AI this structure and add rules like &lt;strong&gt;“do not use the word synergy”&lt;/strong&gt; or &lt;strong&gt;“keep it under 120 words,”&lt;/strong&gt; the difference is dramatic. Instead of generic fluff, you get concise, sharp outreach that feels human.&lt;/p&gt;




&lt;h2&gt;
  
  
  Personalization: The Real Game-Changer
&lt;/h2&gt;

&lt;p&gt;Templates are useful, but they’re just the skeleton. The muscle of a great outreach campaign is &lt;strong&gt;personalization&lt;/strong&gt;. Without it, even the best structure falls flat.&lt;/p&gt;

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

&lt;p&gt;Think about your own inbox. If you get a message that clearly went to 500 other people, you delete it without reading. But if someone references your company, your latest LinkedIn post, or a pain point you actually deal with, you pause. You might even reply.&lt;/p&gt;

&lt;p&gt;AI can do personalization at scale — but only if you feed it the right inputs. That means giving it data such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who the recipient is:&lt;/strong&gt; job title, role, decision-making power.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the company does:&lt;/strong&gt; industry, recent projects, unique positioning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What’s happening now:&lt;/strong&gt; case studies, blog posts, product launches, LinkedIn updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your specific angle:&lt;/strong&gt; how your offer relates directly to their situation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s the key: &lt;strong&gt;AI doesn’t guess well&lt;/strong&gt;. If you don’t give it this information, it will invent generic filler. But if you provide real context, it can weave it into your email in ways that feel personal and authentic.&lt;/p&gt;

&lt;p&gt;Yes, you could technically research and write this by hand. For five prospects, sure. For fifty, it starts getting painful. For five hundred? It’s impossible without burning out. And that’s where automation becomes essential.&lt;/p&gt;




&lt;h2&gt;
  
  
  Automating the Grind With AI + n8n
&lt;/h2&gt;

&lt;p&gt;This is where outreach stops being a chore and becomes a system. By combining AI with automation platforms like &lt;strong&gt;n8n&lt;/strong&gt;, you can run campaigns that are personalized, scalable, and efficient.&lt;/p&gt;

&lt;p&gt;Here’s how it looks in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google Sheets&lt;/strong&gt; — load in your prospect list. Maybe it’s 1,000 contacts pulled from LinkedIn Sales Navigator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anymail Finder&lt;/strong&gt; — automatically fetches and verifies the correct email addresses. You only pay for valid ones, so there’s no wasted budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perplexity&lt;/strong&gt; — pulls in live web data for each prospect, so you have current, relevant details to use for personalization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; — writes the emails using your structure, style rules, and the personalized data you collected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n&lt;/strong&gt; — stitches everything together, runs it in sequence, and outputs emails that are ready to send.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The best part? Once you set this up, you’re no longer chained to your laptop, spending nights crafting line after line. The system runs in the background. You wake up the next morning, and your outreach campaign is already written and queued.&lt;/p&gt;

&lt;p&gt;Instead of drowning in repetitive work, you focus on what actually matters: conversations with prospects who are ready to buy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results From the Field
&lt;/h2&gt;

&lt;p&gt;When I tested this system, here’s what happened over a 3-week campaign:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delivery rate:&lt;/strong&gt; 99%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounce rate:&lt;/strong&gt; 1%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open rate:&lt;/strong&gt; 54%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Click-through rate (CTR):&lt;/strong&gt; 18%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prospects amount:&lt;/strong&gt; 300&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emails per person:&lt;/strong&gt; 5 (4 follow-up)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the craziest part? I spent maybe &lt;strong&gt;4 hours total&lt;/strong&gt;. That time was used for setup: preparing the email footer, creating the prospect list, and configuring the workflow. The rest ran automatically.&lt;/p&gt;

&lt;p&gt;Normally, you’d need a full sales development team for those results. With AI and automation, one person can achieve the same — without sacrificing evenings, weekends, or sanity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters More Than Ever
&lt;/h2&gt;

&lt;p&gt;It’s tempting to dismiss all this and say: &lt;strong&gt;“I’ll just write emails myself.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And for small-scale outreach, you’re right. If you’re sending 5 emails to potential partners, don’t overthink it. But scale changes the equation.&lt;/p&gt;

&lt;p&gt;Cold email is fundamentally about &lt;strong&gt;volume&lt;/strong&gt;. The law of large numbers applies — you need to reach enough people for opportunities to materialize. But &lt;strong&gt;pure volume without personalization is spam&lt;/strong&gt;. And &lt;strong&gt;pure personalization without automation is impossible to sustain&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;AI gives you the middle ground: &lt;strong&gt;volume with personalization&lt;/strong&gt;. Instead of choosing between quality and quantity, you finally get both.&lt;/p&gt;

&lt;p&gt;This isn’t just a productivity hack. It’s a competitive edge. Companies that figure out how to combine AI with outreach workflows will simply outpace those who don’t. They’ll send more messages, book more calls, and close more deals, all while spending less time in the weeds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;AI isn’t magic. If you just open ChatGPT and type &lt;strong&gt;“write me a cold email,”&lt;/strong&gt; you’ll get the same robotic junk everyone else is sending. That’s why so many people conclude that AI “doesn’t work for outreach.”&lt;/p&gt;

&lt;p&gt;But the truth is different. AI works brilliantly when you &lt;strong&gt;give it structure, feed it personalization, and connect it to automation tools&lt;/strong&gt; that handle the heavy lifting.&lt;/p&gt;

&lt;p&gt;Think of it like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On its own, AI is a brilliant but unfocused intern. It has potential but doesn’t know what you want.&lt;/li&gt;
&lt;li&gt;With guidance (structure), it becomes a competent writer.&lt;/li&gt;
&lt;li&gt;With resources (personalized data), it becomes persuasive.&lt;/li&gt;
&lt;li&gt;And with automation, it becomes a full-scale outbound team that never sleeps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the power shift. You stop being limited by hours in the day and start running outreach at a scale that would normally require a small army.&lt;/p&gt;

&lt;p&gt;I’ve packaged my exact workflow so you don’t have to reinvent the wheel. You can grab it here:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://lukaszb.gumroad.com/l/lhnfw" rel="noopener noreferrer"&gt;My n8n Workflow&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AI isn’t about replacing human skill. It’s about multiplying it. And in B2B outreach, that multiplier can be the difference between sending a handful of emails that get ignored — and running a campaign that actually drives deals.&lt;/p&gt;

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

&lt;p&gt;PS: “I sent” over 600 emails walking on the Mountains, for a whole week, found 4 clients, and spent a great time ;)&lt;/p&gt;

</description>
      <category>marketing</category>
      <category>email</category>
      <category>n8n</category>
      <category>ai</category>
    </item>
    <item>
      <title>ChatGPT-5 Is Amazing. You don’t know how to use this properly. I show you why.</title>
      <dc:creator>Łukasz Blania</dc:creator>
      <pubDate>Sun, 07 Sep 2025 10:11:00 +0000</pubDate>
      <link>https://dev.to/lukasz_blania_4b7d226fa2a/chatgpt-5-is-amazing-you-dont-know-how-to-use-this-properly-i-show-you-why-2hf1</link>
      <guid>https://dev.to/lukasz_blania_4b7d226fa2a/chatgpt-5-is-amazing-you-dont-know-how-to-use-this-properly-i-show-you-why-2hf1</guid>
      <description>&lt;p&gt;Let’s talk like humans for a second. You typed a vague thought into a text box, hit Enter, and expected cinematic genius to roll out of the machine like a red carpet. Instead, you got something… meh. You sighed. “ChatGPT-5 is worse than 4o!” you posted, shaking your fist at the cloud.&lt;/p&gt;

&lt;p&gt;I’ve got news that will both &lt;strong&gt;empower&lt;/strong&gt; you and mildly annoy you: &lt;strong&gt;GPT-5 is better than GPT-4o&lt;/strong&gt; for most real work — agents, tool use, coding, long context, instruction following — but it &lt;strong&gt;punishes sloppy prompts&lt;/strong&gt; more than its predecessors. The model is more &lt;strong&gt;steerable&lt;/strong&gt;, more &lt;strong&gt;systematic&lt;/strong&gt;, and far more &lt;strong&gt;literal&lt;/strong&gt;. If you tell it where to go, it’ll drive. If you say “take me somewhere nice,” don’t be surprised when you end up in a parking lot with decent lighting.&lt;/p&gt;

&lt;p&gt;This article is a friendly deep-dive into how to get &lt;strong&gt;consistently great results with GPT-5&lt;/strong&gt;. We’ll quickly cover what changed from the GPT-4o era, why your results might feel bland, how the new modes (&lt;strong&gt;Instant&lt;/strong&gt;, &lt;strong&gt;Auto&lt;/strong&gt;, &lt;strong&gt;Thinking&lt;/strong&gt;, and &lt;strong&gt;Pro&lt;/strong&gt;) actually work, and a handful of practical snippets — straight from the playbook of people who use GPT-5 all day — to help you write prompts that don’t waste tokens or time.&lt;/p&gt;

&lt;p&gt;Grab a coffee. I’ll keep it conversational, a little dry-humored, and very practical.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Changed: From a Zoo of Models to One Agentic System
&lt;/h2&gt;

&lt;p&gt;In the GPT-4o days, you often had to choose your fighter — a model for speed, another for multimodal, another for reasoning, and so on. &lt;strong&gt;GPT-5 simplifies that mental overhead.&lt;/strong&gt; Think of it less like “a model” and more like an agentic engine with multiple gears:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;unified core&lt;/strong&gt; (one flagship model) that routes internally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modes&lt;/strong&gt; that change how hard it thinks and how verbose it speaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent-first behavior&lt;/strong&gt;: better at calling tools, following rules, and persisting plans across steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stronger instruction adherence&lt;/strong&gt;: if your instructions conflict or are vague, GPT-5 will spend effort reconciling that mess instead of producing gold from chaos.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical effect is: &lt;strong&gt;less menu anxiety, more steering power&lt;/strong&gt;. But you have to actually steer.&lt;/p&gt;




&lt;h2&gt;
  
  
  “My Results Are Terrible” — Why That Happens with GPT-5
&lt;/h2&gt;

&lt;p&gt;Let’s get the uncomfortable bit out of the way: &lt;strong&gt;GPT-5 is allergic to ambiguity&lt;/strong&gt;. GPT-4o was often forgiving. You could toss a fuzzy prompt at it and it would do a decent job guessing your intent. GPT-5 can still guess — but it’s trained to &lt;strong&gt;respect your constraints and follow your process&lt;/strong&gt; when you give it one. When you don’t, it can over-invest in clarification or under-deliver because the target was never pinned down.&lt;/p&gt;

&lt;p&gt;Here are the three most common causes of “bad answers” with GPT-5:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vague goals&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;“Write a plan” is not a goal.&lt;/li&gt;
&lt;li&gt;“Draft a 7-step launch plan for a two-person SaaS, each step ≤120 words, with a simple checklist and a 14-day timeline” is a goal.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contradictory rules&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;“Never ask the user to confirm anything” vs. “Always obtain explicit consent before booking” is the kind of conflict that sends GPT-5 into polite paralysis. It tries to satisfy both. Resolve conflicts yourself.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing stop conditions&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Agents need to know when to stop. If you don’t define finish lines, they’ll keep circling the track — or stop too early. You decide the lap count.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix: be explicit about what you want, how you want it, how far the model should explore, and when it’s allowed to proceed despite uncertainty.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Modes: Instant, Auto, Thinking, Pro — What They Actually Do
&lt;/h2&gt;

&lt;p&gt;GPT-5 exposes four working styles. These aren’t just “speed settings”; they’re behavior contracts. Use the right one for the job, and your life gets easier.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Instant — “Answer fast, don’t overthink it”
&lt;/h3&gt;

&lt;p&gt;Instant mode is the one that answers as quickly as possible, often sacrificing depth and nuance to give you something fast and usable. It shines when the task is simple — like rewriting a sentence, summarizing a short passage, or giving a factual answer — but it won’t bother with complex reasoning or edge cases. If you ask it to solve a real problem, it will simply hand you the quickest version of an answer and move on.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;When to use:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Quick summaries, short emails, simple rewrites, straightforward Q&amp;amp;A.&lt;/li&gt;
&lt;li&gt;Latency-sensitive interfaces (chat widgets, quick drafts).&lt;/li&gt;
&lt;li&gt;You already know what you want; you just need it formatted or rephrased.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Low latency, low cost.&lt;/li&gt;
&lt;li&gt;Great when the task is clear and bounded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pitfalls:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Doesn’t wander far. If the task is fuzzy, Instant will give you a tidy version of your fuzziness.&lt;/li&gt;
&lt;li&gt;Minimal context gathering; it won’t go spelunking unless told.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to prompt for Instant:&lt;/strong&gt;&lt;br&gt;
Why this works: you’ve set a tiny budget, a clear finish line, and a crisp format. Instant thrives on constraints.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
Search depth: very low&lt;br&gt;
Absolute max tool calls: 2&lt;br&gt;
Proceed with the best available answer even if not fully certain.&lt;br&gt;
Stop when output meets the format and constraints below.&lt;br&gt;
&lt;br&gt;
&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Audience: startup founders, non-technical&lt;/li&gt;
&lt;li&gt;Length: ≤ 180 words&lt;/li&gt;
&lt;li&gt;Format: 3 bullets + 1-sentence CTA&lt;/li&gt;
&lt;li&gt;Tone: concise, friendly, no fluff
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  2) Auto — “Pick the right depth for me”
&lt;/h2&gt;

&lt;p&gt;Auto mode is the balanced choice. It adapts to the difficulty of the task, behaving like Instant when things are straightforward, but taking more time and reasoning when the problem requires it. This makes it the most practical mode for everyday use, because you don’t have to decide how much thinking the model should do — it figures it out on its own. The catch is that if your instructions are vague, Auto might overanalyze and spend too much time gathering context before giving you what you want.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;When to use:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most day-to-day tasks where you don’t want to micromanage.&lt;/li&gt;
&lt;li&gt;The model can decide whether to think shallow or deep.&lt;/li&gt;
&lt;li&gt;You’re okay with it calling tools and making a plan if needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Balanced. Often the best default.&lt;/li&gt;
&lt;li&gt;Can switch gears mid-task: skim where trivial, dig where tricky.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pitfalls:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your instructions are vague, Auto might “gather context” longer than you expected.&lt;/li&gt;
&lt;li&gt;If your rules conflict, Auto burns cycles reconciling them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to prompt for Auto (calibrated):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
Goal: get just enough context to act.&lt;br&gt;
Method:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start broad, then one focused batch of subqueries.&lt;/li&gt;
&lt;li&gt;Deduplicate; cache; avoid repeated searches.
Early stop:&lt;/li&gt;
&lt;li&gt;You can name exact items to change or produce.
Escalate once:&lt;/li&gt;
&lt;li&gt;If signals conflict, do one refined batch, then act.


Keep going until all subtasks in the plan are done.
Do not hand back on uncertainty—proceed with the most reasonable assumption, and document it in the summary.
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3) Thinking — “Take your time, reason deeply, chain steps”
&lt;/h2&gt;

&lt;p&gt;Thinking mode is where GPT-5 goes deep. Instead of rushing, it breaks the problem apart, makes a plan, and follows through step by step. This mode is built for accuracy, analysis, and complex reasoning. It’s slower and more expensive, but if you want a model that can handle multi-step research, write thorough strategies, or work inside large codebases, this is the gear you want. Without clear instructions, however, it can end up overthinking and generating more than you bargained for.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;When to use:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complex multi-step problems, long-horizon tasks, architecture, tricky analysis, non-trivial refactors, research synthesis.&lt;/li&gt;
&lt;li&gt;Anywhere accuracy, edge cases, and planning matter more than speed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The highest quality reasoning and decomposition.&lt;/li&gt;
&lt;li&gt;Excellent at following multi-stage processes and double-checking work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pitfalls:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Slower and more expensive.&lt;/li&gt;
&lt;li&gt;If you don’t set stop conditions, it may over-invest in “thoroughness.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to prompt for Thinking (with guardrails):&lt;/strong&gt;&lt;/p&gt;



&lt;ul&gt;
&lt;li&gt;Decompose the request into a numbered plan (max 7 steps).&lt;/li&gt;
&lt;li&gt;Before executing, validate the plan against constraints and risks.&lt;/li&gt;
&lt;li&gt;After each step, self-check: does this meet the acceptance criteria?

&lt;/li&gt;
&lt;li&gt;Output passes all format checks.&lt;/li&gt;
&lt;li&gt;No TODOs or placeholders remain.&lt;/li&gt;
&lt;li&gt;Edge cases addressed: [list your edge cases].

&lt;/li&gt;
&lt;li&gt;Total tool calls: ≤ 6 unless a blocker is detected.&lt;/li&gt;
&lt;li&gt;If a blocker is detected, explain briefly, then request explicit permission to exceed.

Pro move: pair Thinking with the Responses API and reuse &lt;code&gt;previous_response_id&lt;/code&gt; across turns. This preserves the model’s plan/context without re-paying for it each time.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4) Pro — “All-in performance with ”
&lt;/h2&gt;

&lt;p&gt;Pro mode is the perfectionist. It doesn’t just think deeply like the Thinking mode, it also packages the result neatly, with polish, structure, and presentation. This makes it the mode for high-stakes tasks: executive memos, legal drafts, detailed proposals, or anything where you want the output to look like it came from a professional rather than an assistant. It is the slowest and most resource-intensive option, and sometimes it’s simply overkill, but when the final product matters, this is the mode that delivers.(Only available in pro plan(200$/month))&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;When to use:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High-stakes deliverables where both the thinking and the final writing must be excellent: investor memos, legal policy drafts, system designs pitched to executives, production-grade code proposals.&lt;/li&gt;
&lt;li&gt;Scenarios where tone, formatting, and completeness are part of the spec.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Combines depth with editorial quality and strong instruction adherence.&lt;/li&gt;
&lt;li&gt;Great at keeping the big picture while nailing the details.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pitfalls:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overkill for quick tasks.&lt;/li&gt;
&lt;li&gt;If your prompt is vague, you’ll pay more to get a beautifully formatted shrug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to prompt for Pro (quality spec):&lt;/strong&gt;&lt;/p&gt;



&lt;ul&gt;
&lt;li&gt;Structure: executive summary → details → risks → next steps.&lt;/li&gt;
&lt;li&gt;Evidence: cite sources or assumptions inline; mark assumptions clearly.&lt;/li&gt;
&lt;li&gt;Style: plain English, active voice, short paragraphs.&lt;/li&gt;
&lt;li&gt;Review pass: do a final contradiction scan; align with acceptance criteria.

&lt;/li&gt;
&lt;li&gt;Provide a clean deliverable plus a 5-line TL;DR.&lt;/li&gt;
&lt;li&gt;Include a checklist the reader can act on immediately.
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Prompt Patterns That Work in GPT-5 (And Why)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Contracts beat vibes
&lt;/h3&gt;

&lt;p&gt;Define output contracts, budgets, stop conditions, and escape hatches (“Proceed even if not fully certain”). GPT-5 respects contracts.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Remove contradictions at the source
&lt;/h3&gt;

&lt;p&gt;If your process has a real exception (“In emergencies, skip patient lookup and give 911 guidance”), write that exception explicitly. The model is obedient; don’t make it choose between rule parents.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Use “tool preambles” to keep humans oriented
&lt;/h3&gt;

&lt;p&gt;If your agent will run multiple steps or edit files, have it front-load the plan and narrate progress succinctly.&lt;/p&gt;



&lt;ul&gt;
&lt;li&gt;Rephrase the user goal briefly.&lt;/li&gt;
&lt;li&gt;Outline a step-by-step plan.&lt;/li&gt;
&lt;li&gt;Announce each tool call and why.&lt;/li&gt;
&lt;li&gt;Summarize: planned vs. completed.

4) Control eagerness, don’t just complain about it
Too much “research”? Tighten &lt;code&gt;&amp;lt;context_gathering&amp;gt;&lt;/code&gt;.
Too many clarifying questions? Add &lt;code&gt;&amp;lt;persistence&amp;gt;&lt;/code&gt; and tell it to assume and proceed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5) Verbosity is a dial
&lt;/h3&gt;

&lt;p&gt;There’s a verbosity parameter (final answer length) and your prompt can override it locally. For instance: global verbosity: low, but inside code tools ask for “high verbosity” diffs and comments. You’ll get tight narration, but richly explained code edits.&lt;/p&gt;




&lt;h2&gt;
  
  
  Practical Snippets You Can Paste Today
&lt;/h2&gt;

&lt;p&gt;A. The “Atomic Task” Skeleton (great for Instant/Auto)&lt;br&gt;
Task: Rewrite the following draft into a 120–150 word LinkedIn post for non-technical founders.&lt;br&gt;
Include: a hook (1 sentence), 3 value bullets, 1 CTA sentence.&lt;br&gt;
Constraints: avoid buzzwords; no hashtags; plain English.&lt;br&gt;
Stop when: length and structure are satisfied.&lt;br&gt;
If uncertain: proceed with best-effort and leave a one-line note of assumptions at the end.&lt;br&gt;
Draft:&lt;br&gt;
&lt;code&gt;[PASTE]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;B. The “One-Batch Research” Guardrail (Auto)&lt;br&gt;
&lt;code&gt;&amp;lt;context_gathering&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run exactly one parallel batch of searches.&lt;/li&gt;
&lt;li&gt;Read top 3 hits per query; deduplicate.&lt;/li&gt;
&lt;li&gt;Early stop when ~70% of sources converge on the same answer.&lt;/li&gt;
&lt;li&gt;No second batch unless a contradiction blocks action.
&lt;code&gt;&amp;lt;/context_gathering&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;C. The “Thinking Pass” for Complex Work&lt;br&gt;
&lt;code&gt;&amp;lt;planning&amp;gt;&lt;/code&gt;&lt;br&gt;
1) Draft a mini-rubric (hidden) with 5–7 criteria for excellence.&lt;br&gt;
2) Produce a numbered plan (≤7 steps) to satisfy the rubric.&lt;br&gt;
3) Execute step-by-step; after each, self-check against the rubric.&lt;br&gt;
4) At the end, summarize residual risks or uncertainties.&lt;br&gt;
&lt;code&gt;&amp;lt;/planning&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;D. The “Code Edit Rules” (Frontend example)&lt;br&gt;
&lt;code&gt;&amp;lt;code_editing_rules&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clarity first: descriptive names, small components, minimal props.&lt;/li&gt;
&lt;li&gt;Consistency: Tailwind spacing multiples of 4; 1 neutral + ≤2 accents.&lt;/li&gt;
&lt;li&gt;Stack defaults: Next.js (TS), Tailwind, shadcn/ui, Lucide, Zustand.&lt;/li&gt;
&lt;li&gt;Directory: /src/app, /components, /hooks, /stores, /lib, /types.&lt;/li&gt;
&lt;li&gt;Deliverables: a focused diff + short rationale + test notes.
&lt;code&gt;&amp;lt;/code_editing_rules&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;E. The “Finish Line” Contract (for any mode)&lt;br&gt;
&lt;code&gt;&amp;lt;acceptance_criteria&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Format exactly as specified below.&lt;/li&gt;
&lt;li&gt;No placeholders or TODOs.&lt;/li&gt;
&lt;li&gt;Edge cases addressed: &lt;code&gt;[list]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Final: a 5-line TL;DR the reader can act on.
&lt;code&gt;&amp;lt;/acceptance_criteria&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Cookbook-Style Moves (Lifted from Real Usage)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Persist reasoning between steps (Responses API)
&lt;/h3&gt;

&lt;p&gt;When your agent must call tools across turns, reuse &lt;code&gt;previous_response_id&lt;/code&gt;. You’ll avoid “re-planning tax” and keep latency sane on long tasks.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Minimal Reasoning ≠ Minimal Guidance
&lt;/h3&gt;

&lt;p&gt;If you choose minimal/low reasoning for speed, compensate with more explicit planning in the prompt. Example: ask the model to output a 4-bullet “what I’m about to do” before it does it. Your bullets become its scaffolding.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Calibrate questions vs. assumptions
&lt;/h3&gt;

&lt;p&gt;If you hate clarifying questions mid-flow, say so:&lt;br&gt;
“Do not hand back to the user for confirmation; make the most reasonable assumption and proceed. Document assumptions at the end.”&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Prefer local verbosity overrides
&lt;/h3&gt;

&lt;p&gt;Short final answers, verbose code diffs. Or vice versa. Tell it where to spend words.&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Use metaprompting to improve your prompt
&lt;/h3&gt;

&lt;p&gt;Stuck? Ask GPT-5: “Given this prompt and this undesired behavior, what minimal edits would you make to elicit &lt;code&gt;[desired behavior]&lt;/code&gt;?” You’ll get direct, actionable nips and tucks instead of a full rewrite.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Guided Tour: Matching Tasks to Modes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;th&gt;Prompt Tip&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rephrase email, fix tone&lt;/td&gt;
&lt;td&gt;Instant&lt;/td&gt;
&lt;td&gt;Clear target, low risk&lt;/td&gt;
&lt;td&gt;Define length, audience, tone, and stop condition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Draft landing page copy&lt;/td&gt;
&lt;td&gt;Auto&lt;/td&gt;
&lt;td&gt;Some thinking, some speed&lt;/td&gt;
&lt;td&gt;Provide section outline + word budgets per section&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compare 3 vendor APIs, decide&lt;/td&gt;
&lt;td&gt;Thinking&lt;/td&gt;
&lt;td&gt;Research + synthesis + decision&lt;/td&gt;
&lt;td&gt;One-batch research + acceptance criteria + TL;DR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large refactor (multi-file)&lt;/td&gt;
&lt;td&gt;Thinking/Pro&lt;/td&gt;
&lt;td&gt;Planning, code quality, diffs&lt;/td&gt;
&lt;td&gt;Code rules + plan + test notes + finish line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exec memo with risks &amp;amp; next steps&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;Quality + polish + structure&lt;/td&gt;
&lt;td&gt;Quality bar + handoff checklist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bug triage in a repo&lt;/td&gt;
&lt;td&gt;Auto → Thinking if complex&lt;/td&gt;
&lt;td&gt;Start lean, deepen if needed&lt;/td&gt;
&lt;td&gt;Plan first; escalate only once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Fixing the Five Classic Anti-Patterns
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;“Write me a strategy”&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Better:&lt;/strong&gt; “Write a 7-step GTM strategy for a 2-person SaaS selling a $29/mo analytics add-on to Shopify stores. Each step ≤120 words; include risks and a day-by-day 14-day plan.”&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Be creative but concise but also very detailed”&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick a lane.&lt;/strong&gt; If you must blend: “Use crisp, concrete language; short paragraphs; examples over adjectives. Max 500 words. Include 2 concrete examples.”&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Summarize this PDF” (no audience)&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Better:&lt;/strong&gt; “Summarize for CFO who needs to decide by Friday. Extract 3 numbers, 3 risks, 3 upside factors. Max 200 words + 5-line TL;DR.”&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Improve the code” (no definition of “improve”)&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Better:&lt;/strong&gt; “Refactor for readability and testability: extract components, remove dead code, add 3 unit tests. Keep behavior identical. Explain diff in 5 bullets.”&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Research everything about X”&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Better:&lt;/strong&gt; “Run one batch of searches; read top 3 results; stop when sources converge. Produce a 10-bullet executive summary + links + open questions.”&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Tiny Prompts That Save Hours
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;“Assume don’t ask” switch:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;“Do not ask for clarifications mid-task. When uncertain, choose the most reasonable assumption, proceed, and log assumptions in the final summary.”&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;“Don’t wander” leash:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;“Limit context gathering to one batch; no second batch unless contradictions block progress.”&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;“No fluff” pressure:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;“Plain English. No metaphors. No analogies unless requested. Replace adjectives with numbers or examples.”&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;“Rubric-first build” for greenfield:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;“Before building, silently create a 6-point rubric for a world-class &lt;code&gt;[deliverable]&lt;/code&gt;. Use it to self-check each step; do not show the rubric.”&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;“Finish line” clarity:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;“Stop only when the deliverable meets the acceptance criteria; otherwise continue iterating.”&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;




&lt;h2&gt;
  
  
  A Word on Tone, Humor, and Human-ness
&lt;/h2&gt;

&lt;p&gt;You’ll notice GPT-5 can feel a little… serious. If your audience expects warmth or punch, ask for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Friendly, lightly humorous tone.”&lt;/li&gt;
&lt;li&gt;“Speak directly to the reader (‘you’), like a helpful colleague.”&lt;/li&gt;
&lt;li&gt;“Short paragraphs. Occasional one-liner for levity.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if you want zero fluff: say “No jokes, no rhetorical questions.” The model will comply. It’s obedient, not psychic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting It All Together: A Mini Playbook
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Choose the model:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instant:&lt;/strong&gt; quick, bounded tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto:&lt;/strong&gt; default for most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thinking:&lt;/strong&gt; complex, multi-step, accuracy-critical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pro:&lt;/strong&gt; high-stakes, polished deliverables.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define the deliverable:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Audience, length, structure, constraints, finish line.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steer eagerness:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;context_gathering&amp;gt;&lt;/code&gt; for budgets and early stops.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;persistence&amp;gt;&lt;/code&gt; for autonomy and fewer clarifying questions.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specify quality:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;quality_bar&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;acceptance_criteria&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Verbosity rules (where to be terse vs. detailed).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use tool preambles:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Plan → narrate → summarize. Humans love to see the map.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist reasoning across turns:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Responses API + &lt;code&gt;previous_response_id&lt;/code&gt;. Pay once, reuse the plan.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep prompts contradiction-free:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Resolve rule conflicts up front. Explicit exceptions beat implicit hope.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iterate:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;When results disappoint, ask GPT-5 to propose minimal prompt edits to reach your desired behavior. Ship the improved version.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Punchline Without the Punch
&lt;/h2&gt;

&lt;p&gt;If GPT-4o was your friendly generalist who tried to read your mind, &lt;strong&gt;GPT-5 is your meticulous colleague&lt;/strong&gt; who will do exactly what you asked — and look at you expectantly if what you asked was unclear. It’s smarter, more persistent, and wildly capable. But it is not a magician. Vague in, vague out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write contracts, not vibes.&lt;/strong&gt; Choose the right mode. Set budgets and finish lines. Give it a quality bar to clear and an escape hatch when uncertainty isn’t worth the wait. Do that, and GPT-5 stops feeling “worse than before” and starts feeling like the teammate you brag about.&lt;/p&gt;

&lt;p&gt;If you still want “somewhere nice” without directions, I hear the parking lot has great lighting.&lt;/p&gt;

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

</description>
      <category>chatgpt</category>
      <category>promptengineering</category>
      <category>openai</category>
      <category>gpt5</category>
    </item>
  </channel>
</rss>
