<?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: JonathanSolvesProblems</title>
    <description>The latest articles on DEV Community by JonathanSolvesProblems (@jonathansolvesstuff).</description>
    <link>https://dev.to/jonathansolvesstuff</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4025240%2F8b439bff-c8c7-4012-b315-f48e6d2c1d60.png</url>
      <title>DEV Community: JonathanSolvesProblems</title>
      <link>https://dev.to/jonathansolvesstuff</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonathansolvesstuff"/>
    <language>en</language>
    <item>
      <title>Passion is the one thing you are not supposed to be able to fake. So I built a machine that manufactures it.</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Sun, 12 Jul 2026 01:52:27 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/passion-is-the-one-thing-you-are-not-supposed-to-be-able-to-fake-so-i-built-a-machine-that-5e58</link>
      <guid>https://dev.to/jonathansolvesstuff/passion-is-the-one-thing-you-are-not-supposed-to-be-able-to-fake-so-i-built-a-machine-that-5e58</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-07-09"&gt;Weekend Challenge: Passion Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;The prompt for this challenge was passion, so I took the most literal possible&lt;br&gt;
run at it.&lt;/p&gt;

&lt;p&gt;Every World Cup app ever made is built for people who already care. Fixtures,&lt;br&gt;
scores, brackets, league tables, fantasy teams. Every one of them assumes you&lt;br&gt;
turned up with a side. Nobody builds anything for the person who wants to care&lt;br&gt;
and has nobody to care about, which is most of the planet: a few billion people&lt;br&gt;
will watch this tournament, and the overwhelming majority of them have no dog in&lt;br&gt;
any of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Your Side&lt;/strong&gt; is for them. You name any two nations. It goes and reads their&lt;br&gt;
actual history, picks the one you were always meant to love, tells you why, and a&lt;br&gt;
stadium announcer swears you in.&lt;/p&gt;

&lt;p&gt;Two nations in. One side out, and a reason to mean it.&lt;/p&gt;

&lt;p&gt;The interesting problem is that you cannot fake this with a nice adjective. If&lt;br&gt;
the app tells you Ghana and Uruguay have a bitter history and the history is&lt;br&gt;
invented, then the passion it hands you is counterfeit, and the moment you look&lt;br&gt;
it up you will feel stupid and never open it again. Manufactured passion only&lt;br&gt;
works if the thing it is built out of is true.&lt;/p&gt;

&lt;p&gt;So the whole architecture is bent around that one constraint.&lt;/p&gt;


&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;
&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;a href="https://pick-your-side.vercel.app" rel="noopener noreferrer"&gt;pick-your-side.vercel.app&lt;/a&gt;&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Turn your sound on.&lt;/strong&gt; Half of this app is audio.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/fND6klvdVqY"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fukxq3n6co77f29j5yzow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fukxq3n6co77f29j5yzow.png" alt="The Pick Your Side landing page. Huge white type on near black reads " width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try &lt;strong&gt;Uruguay against Ghana&lt;/strong&gt;. It will side with Ghana, and it will tell you why:&lt;br&gt;
Luis Suarez's handball on the line in 2010, the penalty Asamoah Gyan hit off the&lt;br&gt;
crossbar in the last minute, the first African side ever to stand that close to a&lt;br&gt;
semi-final. It also knows Ghana beat them 2-0 in the group stage a few days ago,&lt;br&gt;
and it still argues the wound has not closed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa5km4r2f4pvpgnzvqoo7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa5km4r2f4pvpgnzvqoo7.png" alt="The reveal. The screen has flooded green and the word GHANA fills it, with the verdict " width="800" height="558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Or try &lt;strong&gt;England against Germany&lt;/strong&gt;, if you want to watch it side with the team&lt;br&gt;
that has not won anything since 1966.&lt;/p&gt;

&lt;p&gt;Here is the part I am proudest of. Every fact above came from a live search, and&lt;br&gt;
the app will show you exactly which pages it read:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fki4w8t189vlnla3ue85l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fki4w8t189vlnla3ue85l.png" alt="The Verified panel, expanded. On the left, the six live searches Gemini actually ran. On the right, the six pages it actually read, each one a link you can open and check." width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/JonathanSolvesProblems" rel="noopener noreferrer"&gt;
        JonathanSolvesProblems
      &lt;/a&gt; / &lt;a href="https://github.com/JonathanSolvesProblems/pick-your-side" rel="noopener noreferrer"&gt;
        pick-your-side
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Pick Your Side&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;The World Cup is on and you do not have a team.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Every World Cup app ever built is for the people who already care. This one is
for everybody else. Name any two nations. It reads their real history, picks the
side you were always meant to love, and a stadium announcer swears you in.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/weekend-2026-07-09" rel="nofollow"&gt;DEV Weekend Challenge: Passion Edition&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The interesting part is that none of the history is invented.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Gemini researches, grounded in Google Search.&lt;/strong&gt;
The first call has the Google Search tool switched on. Gemini goes and finds the
actual head to head record, the actual famous match, and where both sides
actually stand in the tournament being played right now. It comes back with the
pages it read and the searches it ran, and the app shows you both. Ask it about
Morocco and…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/JonathanSolvesProblems/pick-your-side" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;





&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gemini never gets to work from memory
&lt;/h3&gt;

&lt;p&gt;Because of that constraint, it runs twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Call one goes and finds out.&lt;/strong&gt; Google Search grounding is switched on, and&lt;br&gt;
Gemini is told to report only what it can verify: every World Cup meeting between&lt;br&gt;
the two nations with year and score, the single most famous match, what each side&lt;br&gt;
has actually won, and where both of them stand in the tournament being played&lt;br&gt;
right now.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;research&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;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&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;gemini-2.5-flash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Research &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;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; and &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&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; ... Be concise and factual.
    If they have rarely or never played each other, say that plainly
    instead of inventing a rivalry.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;googleSearch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Call two writes the case&lt;/strong&gt;, and it only ever sees the facts call one came back&lt;br&gt;
with. It is held to a &lt;code&gt;responseSchema&lt;/code&gt;, so the output is always the same shape:&lt;br&gt;
a side, a verdict, the history, the stakes, one legendary moment, your reason, a&lt;br&gt;
line you can share, and a script for the announcer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;written&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;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&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;gemini-2.5-flash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`...Here is what was just researched. Every fact you use must
    come from here. Do not add matches, scores or players that are not in it:
    &amp;lt;researched-facts&amp;gt;&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="s2"&gt;&amp;lt;/researched-facts&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;responseMimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="na"&gt;responseSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;revealSchema&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;h3&gt;
  
  
  Why two calls, which is the bit that cost me an hour
&lt;/h3&gt;

&lt;p&gt;I did not want two calls. I wanted one: grounded and schema'd together. The&lt;br&gt;
Gemini docs say Gemini 3 supports exactly that, structured output combined with&lt;br&gt;
Google Search.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, asking for &lt;code&gt;googleSearch&lt;/code&gt; and a &lt;code&gt;responseSchema&lt;/code&gt; in the&lt;br&gt;
same call &lt;strong&gt;does not throw an error&lt;/strong&gt;. It quietly ignores the schema and hands&lt;br&gt;
you back prose.&lt;/p&gt;

&lt;p&gt;That is a genuinely nasty failure. Nothing fails, nothing warns, the call&lt;br&gt;
succeeds, and then your UI does &lt;code&gt;JSON.parse()&lt;/code&gt; on three paragraphs of essay. I&lt;br&gt;
only caught it because I wrote a throwaway script to poke at the API surface&lt;br&gt;
before I wrote any UI, and I printed the raw output instead of trusting the&lt;br&gt;
status code. If I had trusted it, the reveal would have exploded live.&lt;/p&gt;

&lt;p&gt;So: research first, then shape. Which turned out to be the better architecture&lt;br&gt;
anyway, because splitting them meant the facts arrive with sources attached.&lt;/p&gt;
&lt;h3&gt;
  
  
  The model was thinking, and thinking was making it worse
&lt;/h3&gt;

&lt;p&gt;The whole thing was taking twenty six seconds, which is a long time to look at a&lt;br&gt;
loading screen. &lt;code&gt;gemini-2.5-flash&lt;/code&gt; reasons before it answers unless you tell it&lt;br&gt;
not to. I turned it off on both calls. Same model, same prompts, measured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;grounded&lt;/th&gt;
&lt;th&gt;written&lt;/th&gt;
&lt;th&gt;total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;thinking on&lt;/td&gt;
&lt;td&gt;11.6s&lt;/td&gt;
&lt;td&gt;14.7s&lt;/td&gt;
&lt;td&gt;26.3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;thinking off&lt;/td&gt;
&lt;td&gt;5.1s&lt;/td&gt;
&lt;td&gt;1.0s&lt;/td&gt;
&lt;td&gt;6.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four times faster. And the answers got &lt;strong&gt;better&lt;/strong&gt;, which I did not expect. Asked&lt;br&gt;
for a verdict, thinking on, it reasoned its way to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;France ends Morocco's World Cup run&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a news headline. It is not a reason to love anybody. Thinking off:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Unfinished business and a deeper wound&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first call is looking things up and the second is being asked to write.&lt;br&gt;
Neither one is a reasoning problem, and reasoning at them cost me twenty seconds&lt;br&gt;
and the only good line in the output.&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;responseMimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="nx"&gt;responseSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;revealSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;thinkingConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;thinkingBudget&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="c1"&gt;// &amp;lt;- this&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Showing the receipts
&lt;/h3&gt;

&lt;p&gt;Because the grounded call comes back with grounding metadata, I know exactly&lt;br&gt;
which pages Gemini read and which searches it ran. So the app shows you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Verified.&lt;/strong&gt; None of this was invented. Gemini ran 6 live searches and read 6&lt;br&gt;
pages before it made the case.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Expand it and you get the real search strings and links to fifa.com, wikipedia,&lt;br&gt;
fotmob, whatever it actually used. You can go check it. It is the difference&lt;br&gt;
between a story and a fact, and it is the thing I would want if I were the one&lt;br&gt;
being told who to love.&lt;/p&gt;

&lt;p&gt;Here is what convinced me it was worth doing. I asked it about Morocco against&lt;br&gt;
France, and it told me about the 2026 quarter final, a match played days ago.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This was tragically repeated in the 2026 World Cup quarterfinal, with France&lt;br&gt;
again winning 2-0."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No language model knows that from training. It went and looked.&lt;/p&gt;
&lt;h3&gt;
  
  
  Telling it to pick the loser
&lt;/h3&gt;

&lt;p&gt;The instruction that made the biggest difference to how the thing feels:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Choose the side with the better story for a newcomer: the deeper wound, the&lt;br&gt;
longer wait, the thing still unfinished. &lt;strong&gt;Do not default to the more famous or&lt;br&gt;
more successful nation.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without that line it picks Brazil, France, Germany, every time, because they are&lt;br&gt;
the most written about. With it, it picks Morocco over France and argues for the&lt;br&gt;
Atlas Lions. It picks the Netherlands over Senegal and leads with three lost&lt;br&gt;
finals. That single sentence is the difference between an app that reports and an&lt;br&gt;
app that has taste.&lt;/p&gt;
&lt;h3&gt;
  
  
  The voice
&lt;/h3&gt;

&lt;p&gt;Gemini writes the swear-in. ElevenLabs performs it.&lt;/p&gt;

&lt;p&gt;The reveal lands, the nation's colour floods the screen, and a voice comes in&lt;br&gt;
over the top of it. It is the moment the whole thing exists for, and it does not&lt;br&gt;
work on the page as text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&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;textToSpeech&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&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="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="s2"&gt;`[shouting over a stadium crowd] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;script&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;modelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eleven_v3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mp3_44100_128&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;voiceSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;similarityBoost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.65&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;eleven_v3&lt;/code&gt; takes inline audio tags, so &lt;code&gt;[shouting over a stadium crowd]&lt;/code&gt; is&lt;br&gt;
direction for the read, not words that get spoken. Low stability plus high style&lt;br&gt;
is what stops it sounding like an audiobook.&lt;/p&gt;

&lt;p&gt;And then I listened to it, and the problem was obvious. A man was shouting about&lt;br&gt;
a stadium, in total silence. He sounded unhinged.&lt;/p&gt;

&lt;p&gt;So ElevenLabs builds the stadium too. Their sound effects API takes a text&lt;br&gt;
prompt the same way the voice does, and I asked it for two things: a restless&lt;br&gt;
crowd that murmurs under the scouting screen while the app is thinking, and an&lt;br&gt;
eruption for the instant the nation's name lands.&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;await&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textToSoundEffects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A packed football stadium erupting the instant a goal goes in. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A sudden enormous crowd roar, then it settles into a sustained wall &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;of cheering that slowly falls away. No music, no commentary.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;durationSeconds&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The crowd sits at a third of the volume and never competes with the announcer.&lt;br&gt;
Both cues are generated once and committed, so they cost nothing and start&lt;br&gt;
instantly. Sound is on by default, because a muted reveal is just a poster, and&lt;br&gt;
the control to turn it off is on screen the entire time.&lt;/p&gt;

&lt;p&gt;There are three layers under the voice, because I did not want a visitor to&lt;br&gt;
arrive at a silent reveal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;live text to speech of the script Gemini just wrote for that exact matchup&lt;/li&gt;
&lt;li&gt;if that fails or the free quota is spent, a pre-rendered stinger for that
nation, which ships with the app&lt;/li&gt;
&lt;li&gt;if both fail, silence, and the reveal is otherwise unharmed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same matchup, same words, same audio, so it is cached and only the first visitor&lt;br&gt;
to a given pairing costs anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The announcer kept turning up late
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;eleven_v3&lt;/code&gt; is the expressive one. It also takes 4.4 seconds, so the nation's&lt;br&gt;
name would slam onto the screen and then nothing would happen for four seconds&lt;br&gt;
and then a voice would arrive, to an empty room. Worse, I had put the dramatic&lt;br&gt;
pause &lt;strong&gt;after&lt;/strong&gt; the download instead of measuring it from the start of the&lt;br&gt;
reveal, so the network time and the pause stacked and the announcer was&lt;br&gt;
arriving 5.1 seconds late. I only found that because I scripted a browser to&lt;br&gt;
click through the live site and timestamp the moment the audio element actually&lt;br&gt;
started playing, which is not something a screenshot will ever tell you.&lt;/p&gt;

&lt;p&gt;There is also a trap in the audio tags. &lt;code&gt;[shouting over a stadium crowd]&lt;/code&gt; is&lt;br&gt;
direction, not dialogue, but &lt;strong&gt;only on v3&lt;/strong&gt;. Every other model reads it out&lt;br&gt;
loud. I nearly shipped an announcer who introduces himself by saying the words&lt;br&gt;
"shouting over a stadium crowd".&lt;/p&gt;

&lt;p&gt;So the live voice is &lt;code&gt;eleven_flash_v2_5&lt;/code&gt;, which comes back in under a second and&lt;br&gt;
lands on the beat, and the pre-rendered stingers stay on &lt;code&gt;eleven_v3&lt;/code&gt;, where&lt;br&gt;
latency does not exist because they are generated offline.&lt;/p&gt;

&lt;h3&gt;
  
  
  The colours were a lie
&lt;/h3&gt;

&lt;p&gt;The nations carry their real kit colours. Germany's is &lt;code&gt;#111111&lt;/code&gt;. New Zealand's&lt;br&gt;
is &lt;code&gt;#0A0A0A&lt;/code&gt;. The whole design floods a near-black page with the nation's&lt;br&gt;
colour, so picking Germany flooded near-black with near-black and the giant word&lt;br&gt;
GERMANY simply was not there.&lt;/p&gt;

&lt;p&gt;Eight of the forty one nations failed to clear 4.5:1 against the background. The&lt;br&gt;
fix was not to fake the colours: it was to keep the authentic one as the source&lt;br&gt;
of truth and derive the painted one from it, lifting it toward white only as far&lt;br&gt;
as it has to go to be legible. Brazil's yellow comes out untouched. Germany's&lt;br&gt;
black comes out a graphite you can actually read.&lt;/p&gt;

&lt;h3&gt;
  
  
  The design has one idea
&lt;/h3&gt;

&lt;p&gt;The landing page has no colour in it. Near black, white type, a faint green glow&lt;br&gt;
where the pitch would be. The only colour anywhere is a slow ribbon of all 41&lt;br&gt;
nations drifting past the bottom, every side you could end up on, none of them&lt;br&gt;
yours.&lt;/p&gt;

&lt;p&gt;Then you pick, and the reveal floods the entire screen in your nation's colours.&lt;/p&gt;

&lt;p&gt;That is the whole visual argument, and it is the product in one gesture: you have&lt;br&gt;
nothing, and then you have something. Everything on the reveal is driven off the&lt;br&gt;
chosen nation's real kit colours through CSS custom properties, so Morocco is&lt;br&gt;
red, the Netherlands is orange, Argentina is that pale sky blue.&lt;/p&gt;

&lt;p&gt;Flags are images rather than emoji, incidentally, because Windows does not render&lt;br&gt;
flag emoji at all. It falls back to letter pairs. A judge on a Windows laptop&lt;br&gt;
would have seen "AR" where Argentina's flag should be.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best use of Google AI.&lt;/strong&gt; Gemini is the entire product, not a feature of it. It&lt;br&gt;
runs twice: once grounded in Google Search to establish real, current, checkable&lt;br&gt;
facts, and once under a forced &lt;code&gt;responseSchema&lt;/code&gt; to turn those facts into an&lt;br&gt;
argument. The app shows you the pages it read. It knows results from a tournament&lt;br&gt;
that is still being played.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best use of ElevenLabs.&lt;/strong&gt; The swear-in is the emotional payoff of the whole&lt;br&gt;
app, and it is built out of two ElevenLabs APIs. Text to speech is the&lt;br&gt;
announcer: Gemini writes the words for that exact matchup, ElevenLabs is the&lt;br&gt;
voice that makes them land, timed to arrive just behind the nation's name.&lt;br&gt;
Sound effects are the stadium he is shouting into: a restless crowd while the&lt;br&gt;
app is thinking, an eruption when the verdict drops. Turn your sound on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Keeping it standing
&lt;/h2&gt;

&lt;p&gt;The Google AI Studio free tier caps a &lt;strong&gt;project&lt;/strong&gt; at a few hundred requests a&lt;br&gt;
day, across every visitor combined. Two calls per reveal means this app could&lt;br&gt;
serve about 125 people a day in total before handing everyone else an error. Fine&lt;br&gt;
for building. Useless for being read.&lt;/p&gt;

&lt;p&gt;So two things. The app runs on Vertex AI, which needs a billing account and&lt;br&gt;
therefore carries real quota. And the well known matchups are generated ahead of&lt;br&gt;
time by the same grounded pipeline, sources and all, and committed to the repo.&lt;br&gt;
Those answer in half a second and cost nothing. They are cached, not faked.&lt;/p&gt;

&lt;p&gt;When the budget does run out, the failure hands you a matchup that always works&lt;br&gt;
instead of a dead end. I would rather you get a team from a slightly stale cache&lt;br&gt;
than a stack trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not solve
&lt;/h2&gt;

&lt;p&gt;The nations list is curated to 41 sides, so you cannot pick your local club. A&lt;br&gt;
matchup nobody has asked for before takes about twelve seconds, which I dressed&lt;br&gt;
up as a scouting sequence rather than fixed. And the "what is at stake" text goes&lt;br&gt;
stale six hours after it is written, which is a strange thing to have to think&lt;br&gt;
about, and only a problem because the tournament is still being played while you&lt;br&gt;
read this.&lt;/p&gt;

&lt;h2&gt;
  
  
  One last thing
&lt;/h2&gt;

&lt;p&gt;I should probably admit that I am the target user.&lt;/p&gt;

&lt;p&gt;The World Cup is being played in my country. Everyone I know has lost their mind&lt;br&gt;
over it, and for the whole tournament I have been standing in the middle of that&lt;br&gt;
with nothing: no nation, no shirt, no grudge, nobody to shout at. I did not build&lt;br&gt;
this as a demo of an API. I built it because I wanted in and could not find the&lt;br&gt;
door.&lt;/p&gt;

&lt;p&gt;I pointed it at Uruguay and Ghana, because I did not know anything about either&lt;br&gt;
of them.&lt;/p&gt;

&lt;p&gt;It gave me Ghana.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8q2pm76sjig3ua20sb24.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8q2pm76sjig3ua20sb24.png" alt="The share card in Ghana's green and gold. It reads: " width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wrote up the longer version of the build, including the two Gemini traps that&lt;br&gt;
nearly shipped, &lt;a href="https://jonathanandrei.com/blog/pick-your-side-manufactured-passion-gemini-elevenlabs/" rel="noopener noreferrer"&gt;on my own site&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Built by &lt;a href="https://jonathanandrei.com" rel="noopener noreferrer"&gt;Jonathan Andrei&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built an AI agent that runs a local business's weekly marketing (Next.js + Gemini)</title>
      <dc:creator>JonathanSolvesProblems</dc:creator>
      <pubDate>Sat, 11 Jul 2026 13:59:24 +0000</pubDate>
      <link>https://dev.to/jonathansolvesstuff/i-built-an-ai-agent-that-runs-a-local-businesss-weekly-marketing-nextjs-gemini-2i91</link>
      <guid>https://dev.to/jonathansolvesstuff/i-built-an-ai-agent-that-runs-a-local-businesss-weekly-marketing-nextjs-gemini-2i91</guid>
      <description>&lt;p&gt;For the last couple of weeks I've been building &lt;strong&gt;Bloom&lt;/strong&gt;, an AI marketing agent for local businesses like cafes, salons, and gyms. You set it up once, and every week an agent decides what to feature, writes the content in your brand voice, checks its own work, and (on the paid tier) emails your newsletter to your subscribers. On its own.&lt;/p&gt;

&lt;p&gt;It's an early work in progress and I'm sharing it to get honest feedback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;90-second demo:&lt;/strong&gt; &lt;a href="https://www.youtube.com/watch?v=huHCDcUhIGM" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=huHCDcUhIGM&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/huHCDcUhIGM"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

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

&lt;p&gt;Most local owners get under an hour a day for marketing, so it slips, and posting irregularly quietly costs them reach. It's a time-and-consistency problem, not a talent one. I wanted to build the thing that just never skips a week.&lt;/p&gt;

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

&lt;p&gt;You enter your business once (type, city, brand voice). Then every Monday a scheduled agent runs with no human trigger and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;decides the week's angle and promotion&lt;/li&gt;
&lt;li&gt;writes 3 social posts and an email newsletter in your voice&lt;/li&gt;
&lt;li&gt;scores its own draft 0-100 and rewrites it if it falls below the bar&lt;/li&gt;
&lt;li&gt;on the Pro tier, emails the newsletter to your subscribers from a verified domain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every decision is logged to a public activity feed, so you can actually watch it work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that were actually interesting to build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The self-QA gate is real, not decorative.&lt;/strong&gt; After generating, a second Gemini call reviews the draft and scores it. Below the threshold, the agent rewrites once and keeps the better attempt. Making this reliable meant forcing a JSON response schema, because Gemini would occasionally return an array instead of an object and silently break the gate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured generation via Vertex AI.&lt;/strong&gt; Content is Gemini 2.5 Flash through Vertex AI with a forced response schema, so the output is always a clean object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent weekly delivery.&lt;/strong&gt; The sender claims each send atomically, so a newsletter can never go out twice even if two workers fire the same Monday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs on a plain Docker/Traefik box&lt;/strong&gt; as a standalone Next.js server, with a cron sidecar driving the weekly run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack: Next.js, TypeScript, Gemini 2.5 Flash (Vertex AI), Prisma + Neon, Stripe, Resend, Docker + Traefik.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's at (honestly)
&lt;/h2&gt;

&lt;p&gt;It's live. I've run a real payment through it and had it generate and deliver a real newsletter end to end. But it has basically no real users yet, which is exactly why I'm posting. I'd rather hear what's wrong now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, and tell me what's missing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free preview (no card, about a minute):&lt;/strong&gt; &lt;a href="https://bloom.jonathanandrei.com" rel="noopener noreferrer"&gt;https://bloom.jonathanandrei.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd genuinely love feedback on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Would you, or a local business you know, actually use this?&lt;/li&gt;
&lt;li&gt;Does the generated content look good enough to post as-is?&lt;/li&gt;
&lt;li&gt;What would need to be true before you'd pay for it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built for the Build with Gemini XPRIZE hackathon. Happy to answer anything in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>showdev</category>
      <category>nextjs</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
