<?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: Vicente Junior</title>
    <description>The latest articles on DEV Community by Vicente Junior (@vicente_junior_dev).</description>
    <link>https://dev.to/vicente_junior_dev</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%2F3838257%2Fff6de2e1-5a5f-4aab-bdb3-2099aa03009b.jpg</url>
      <title>DEV Community: Vicente Junior</title>
      <link>https://dev.to/vicente_junior_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vicente_junior_dev"/>
    <language>en</language>
    <item>
      <title>Finn 💰 — A Personal Finance Assistant That Lives in WhatsApp</title>
      <dc:creator>Vicente Junior</dc:creator>
      <pubDate>Sat, 25 Apr 2026 15:53:31 +0000</pubDate>
      <link>https://dev.to/vicente_junior_dev/finn-a-personal-finance-assistant-that-lives-in-whatsapp-2phh</link>
      <guid>https://dev.to/vicente_junior_dev/finn-a-personal-finance-assistant-that-lives-in-whatsapp-2phh</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/openclaw-2026-04-16"&gt;OpenClaw Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Finn&lt;/strong&gt; is a personal finance assistant that lives entirely in WhatsApp. No app to install, no dashboard to remember to open — you just message Finn the way you'd message a friend.&lt;/p&gt;

&lt;p&gt;The problem Finn solves is a real one: most personal finance tools require you to change your habits (log into an app, categorize manually, remember later). Finn fits into a habit you already have — checking WhatsApp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it can do:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📝 &lt;strong&gt;Log expenses and income&lt;/strong&gt; via text or voice note (Whisper transcription)&lt;/li&gt;
&lt;li&gt;📄 &lt;strong&gt;Parse credit card PDF invoices&lt;/strong&gt; — send the PDF, review the itemized list, confirm, saved&lt;/li&gt;
&lt;li&gt;🏦 &lt;strong&gt;Parse bank account statement PDFs&lt;/strong&gt; — transactions extracted via a saldo-diff algorithm that works around garbled PDF text&lt;/li&gt;
&lt;li&gt;🔍 &lt;strong&gt;Query spending&lt;/strong&gt; — by period, category, card, or cost center (cross-source: credit cards + bank account + manual entries combined)&lt;/li&gt;
&lt;li&gt;✏️ &lt;strong&gt;Edit or delete&lt;/strong&gt; any saved record by just describing it&lt;/li&gt;
&lt;li&gt;🔒 &lt;strong&gt;Phone whitelist&lt;/strong&gt; — only your number gets a response&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;All four scenarios below are real conversations with a running Finn instance.&lt;/p&gt;




&lt;h3&gt;
  
  
  🟢 Demo 1 — Log a Single Expense
&lt;/h3&gt;

&lt;p&gt;The most common interaction: user mentions an amount and a description. Finn classifies the category and card automatically, shows a confirmation line, and only saves after approval.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   189 on lunch
Finn:  $189 · Food · Mastercard · Me · 2026-04-22 — confirm? ✅

You:   yeah
Finn:  ✅ Saved! Anything else?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the description is ambiguous, Finn asks exactly one clarifying question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   spent 80 today
Finn:  What was it for?

You:   pharmacy
Finn:  $80 · Pharmacy · Mastercard · Me · 2026-04-22 — confirm? ✅

You:   yes
Finn:  ✅ Saved!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Voice notes work the same way — Finn echoes the transcription before acting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   🎙️ [audio: "spent ninety at the grocery store today"]
Finn:  🎙️ _"spent ninety at the grocery store today"_

       $90 · Groceries · Mastercard · Me · 2026-04-22 — confirm? ✅

You:   yes
Finn:  ✅ Saved!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  ✏️ Demo 2 — Change Something
&lt;/h3&gt;

&lt;p&gt;You can correct any saved record in plain language. Finn finds the most recent matching transaction, shows what will change, and waits for confirmation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change the amount:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   the pharmacy one was actually 95, not 80
Finn:  Update $80 → $95 · Pharmacy · Mastercard · Me · 2026-04-22 — confirm? ✅

You:   yes
Finn:  ✅ Updated!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Change the category:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   that lunch from yesterday, mark it as Entertainment
Finn:  Update Food → Entertainment · $189 · Mastercard · Me · 2026-04-22 — confirm? ✅

You:   yes
Finn:  ✅ Updated!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Delete a record:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   delete the $90 grocery entry
Finn:  Delete $90 · Groceries · Mastercard · Me · 2026-04-22 — are you sure? 🗑️

You:   yes
Finn:  ✅ Deleted.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  📄 Demo 3 — Import a Credit Card PDF Invoice
&lt;/h3&gt;

&lt;p&gt;You forward the invoice PDF to the WhatsApp chat. Finn parses it, shows you every line, resolves unknown categories, and bulk-saves on one "yes".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   [sends Mastercard invoice PDF]

Finn:  📄 Mastercard · Due 2026-04-10 · $2,340.00 · 18 items (16 new · 2 skipped)

       📊 Invoice Summary
       Purchases: $2,680.00 · Financed balance (credit): -$520.00 · Interest: +$180.00 · Total due: $2,340.00

       1. ✅ UBER*TRIP
          Mar 12 · $18.50 · Transport · Me

       2. ✅ NETFLIX.COM
          Mar 13 · $15.99 · Entertainment · Me

       3. ✅ AMAZON PRIME
          Mar 14 · $42.30 · Shopping · Me (intl · USD 7.99)

       4. ❓ SQ*DOWNTOWN MKT
          Mar 15 · $34.00 · ❓ · Me

       5. ✅ SHELL GAS STATION
          Mar 17 · $95.00 · Transport · Me

       6. ✅ DELTA AIRLINES 01/03
          Mar 19 · $320.00 · Travel · Me
       ...

       ❓ What category for: #4 SQ*DOWNTOWN MKT ($34.00)?

You:   Groceries

Finn:  Got it. Save all 16 items? (2 duplicates will be skipped)

You:   yes

Finn:  ✅ Saved 16 transactions · Total $2,340.00
       Transport: $210.50 · Shopping: $380.00 · Food: $195.00 · Entertainment: $95.99 · Travel: $320.00 · Other: $1,138.51
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "Invoice Summary" line reconciles the invoice math: &lt;code&gt;Purchases + Interest + Financed balance = Total due&lt;/code&gt;. Finn stores the interest and financed balance as signed rows so &lt;code&gt;SELECT SUM(amount)&lt;/code&gt; always equals the invoice total.&lt;/p&gt;

&lt;p&gt;For image-based (scanned) PDFs, Finn automatically falls back to GPT-4.1 Vision OCR — same output, no extra steps for the user.&lt;/p&gt;




&lt;h3&gt;
  
  
  🔍 Demo 4 — Query Spending
&lt;/h3&gt;

&lt;p&gt;Finn always queries the database — it never answers from memory or inference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total this month:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   how much did I spend this month?
Finn:  📊 All spending — last 60 days
       Total: $6,234.18

       Shopping:     $1,402.10
       Food:           $534.90
       Transport:      $487.20
       Entertainment:  $211.70
       Travel:         $420.00
       Other:        $3,178.28
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;By category:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   breakdown of Transport
Finn:  🚗 Transport — last 60 days
       $487.20 across 8 transactions

       Mar 15 · Uber              $34.90  (Mastercard)
       Mar 20 · Posto Ipiranga   $180.00  (Mastercard)
       Apr 01 · Uber              $28.50  (Mastercard)
       Apr 05 · Shell Gas        $155.00  (Nu)
       Apr 10 · Parking           $22.00  (Bradesco)
       ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cross-source (credit card + bank account):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   did my card payment hit the bank account?
Finn:  Yes — Mar 31 bank debit of $4,782.24 matches your Mastercard invoice exactly. ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A specific period:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:   how much did I spend on Food in March?
Finn:  🍽️ Food — March 2026
       $312.40 across 9 transactions

       Highest: $89.90 at a restaurant on Mar 22
       Daily average: $10.08
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How I Used OpenClaw
&lt;/h2&gt;

&lt;p&gt;The entire agent is an OpenClaw plugin registered as a &lt;code&gt;before_dispatch&lt;/code&gt; hook. Every WhatsApp message — text, voice, or PDF — passes through Finn before OpenClaw does anything else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;WhatsApp → OpenClaw gateway → before_dispatch hook → Finn plugin → OpenAI gpt-4.1 → Supabase
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Plugin Registration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// openclaw.plugin.json&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&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="s2"&gt;finance-agent&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="s2"&gt;version&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="s2"&gt;1.0.0&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="s2"&gt;hooks&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;before_dispatch&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// plugin/src/index.ts&lt;/span&gt;
&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;before_dispatch&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;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&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;phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;senderId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;senderId&lt;/span&gt;

  &lt;span class="c1"&gt;// Phone whitelist — only the owner gets responses&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowedPhones&lt;/span&gt; &lt;span class="o"&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;ALLOWED_PHONES&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;split&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allowedPhones&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;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowedPhones&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phone&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;handled&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;text&lt;/span&gt;&lt;span class="p"&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;// silent ignore for unknown numbers&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;result&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;runAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;phone&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="nx"&gt;mediaType&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;handled&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;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reply&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;
  
  
  The Agent Loop
&lt;/h3&gt;

&lt;p&gt;The core is a tool-use loop over &lt;code&gt;gpt-4.1&lt;/code&gt; with six tools and a maximum of 5 iterations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;save_transaction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persist a confirmed expense or income entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;query_spending&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Query totals, breakdowns, history from Supabase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;save_bulk_transactions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bulk-save confirmed invoice items from a PDF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;save_bank_statement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bulk-save confirmed bank statement rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;update_transaction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Edit a saved record after confirmation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_transaction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete a record after confirmation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The system prompt enforces a strict state machine: the LLM never calls &lt;code&gt;save_transaction&lt;/code&gt; without an explicit user confirmation. The confirmation always uses a canonical format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$45 · Food · Mastercard · Me · 2026-04-22 — confirm? ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Preventing LLM Hallucination on Queries
&lt;/h3&gt;

&lt;p&gt;Even with a well-crafted prompt saying "always call query_spending", the model would sometimes answer "you spent $X on Transport" by inferring from a recently-parsed PDF in context — instead of querying the database. The fix: force &lt;code&gt;tool_choice&lt;/code&gt; on the first iteration for any spending question:&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;SPENDING_Q_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/how much|breakdown|what did I spend/i&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;toolChoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SPENDING_Q_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;userText&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="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="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;function&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;query_spending&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="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  PDF Invoice Pipeline
&lt;/h3&gt;

&lt;p&gt;When a PDF arrives, the plugin routes it before the LLM ever sees it:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pdfText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Scanned/image-based PDF → GPT-4.1 Vision OCR&lt;/span&gt;
  &lt;span class="nx"&gt;invoice&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;parseInvoiceOcr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pdfToImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pdfBuffer&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/Extrato de:.*Agência/i&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;pdfText&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Bank statement&lt;/span&gt;
  &lt;span class="nx"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseStatementBradesco&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pdfText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Text-based credit card invoice&lt;/span&gt;
  &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pdfText&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;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. PDF parsing is harder than it looks.&lt;/strong&gt; The text extraction from &lt;code&gt;pdf-parse&lt;/code&gt; is reliable for prose but unreliable for table columns — numbers get concatenated with adjacent reference codes. The saldo-diff approach was a counterintuitive fix: instead of parsing the value I want, compute it from context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "This month" is not a calendar concept for credit cards.&lt;/strong&gt; A purchase on March 8 appears on an April invoice — so a filter of &lt;code&gt;date &amp;gt;= April 1&lt;/code&gt; would miss it. Finn uses a 60-day rolling window for "this month" queries to cover the billing cycle lag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Forcing &lt;code&gt;tool_choice&lt;/code&gt; prevents silent hallucination.&lt;/strong&gt; The model reliably answers from database queries when forced, and sometimes "just knows" from context when not forced. Both answers look correct — the second one just isn't queryable later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. WhatsApp as an interface has a real adoption advantage.&lt;/strong&gt; The friction of opening a dedicated finance app is the #1 reason people stop using them. A chat interface that's already open all day has zero switching cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Security in layers.&lt;/strong&gt; OpenClaw's &lt;code&gt;allowFrom&lt;/code&gt; whitelist blocks at the gateway level. &lt;code&gt;ALLOWED_PHONES&lt;/code&gt; adds an application-level check. Supabase rows are scoped by &lt;code&gt;phone&lt;/code&gt; with RLS. Each layer is independent — if one fails, the others still hold.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Channel&lt;/td&gt;
&lt;td&gt;WhatsApp via OpenClaw&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;TypeScript, Node.js 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;OpenClaw (&lt;code&gt;before_dispatch&lt;/code&gt; hook)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;td&gt;OpenAI gpt-4.1 (tool-use loop)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;Supabase (PostgreSQL + Row Level Security)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDF parsing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pdf-parse&lt;/code&gt; + custom text parsers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vision OCR&lt;/td&gt;
&lt;td&gt;GPT-4.1 Vision (scanned PDFs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;OpenAI Whisper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;pm2 on a VPS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/vicente-r-junior/finn" rel="noopener noreferrer"&gt;github.com/vicente-r-junior/finn&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>openclawchallenge</category>
    </item>
    <item>
      <title>Notion Life Review OS — Log your day to Notion from WhatsApp using AI</title>
      <dc:creator>Vicente Junior</dc:creator>
      <pubDate>Sun, 29 Mar 2026 12:26:25 +0000</pubDate>
      <link>https://dev.to/vicente_junior_dev/notion-life-review-os-log-your-day-to-notion-from-whatsapp-using-ai-3g3m</link>
      <guid>https://dev.to/vicente_junior_dev/notion-life-review-os-log-your-day-to-notion-from-whatsapp-using-ai-3g3m</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/notion-2026-03-04"&gt;Notion MCP Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Notion Life Review OS&lt;/strong&gt; is a WhatsApp assistant that captures your day and organizes everything in your own Notion workspace — from a single message.&lt;/p&gt;

&lt;p&gt;You send something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Worked on the API integration today. Need to present to the client next Thursday. Also figured out why our Redis connection was dropping."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It extracts a task, a project, a learning, and your mood. Asks you to confirm. Saves everything to the right Notion database. No forms. No clicking. No friction.&lt;/p&gt;

&lt;p&gt;The core idea is simple: your day lives in WhatsApp already. You're already typing there. So why open another tool?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It also works the other way.&lt;/strong&gt; Ask it anything:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What tasks are due this week?"&lt;br&gt;
"What did I learn this week?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And you can manage your Notion schema directly from WhatsApp — even via voice:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Add a column called Who, select type, to the Tasks table"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The new field is available on the very next message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One thing I really liked about how this came together:&lt;/strong&gt; the project and task structure is completely generic. You can use it for work — a project called "API Backend" with tasks like "Deploy to production". But it works just as well for a grocery list — project "Supermarket", tasks "milk, eggs, bread". Or a personal to-do list. The system doesn't care. It just captures what you tell it and puts it in the right place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video Demo
&lt;/h2&gt;

&lt;p&gt;&lt;iframe src="https://player.mux.com/Tkqz82m9uzGSX01TCH2awdaxibvQtAjBQ6p5DvuwpHGg" width="710" height="399"&gt;
&lt;/iframe&gt;

&lt;/p&gt;

&lt;h2&gt;
  
  
  Show me the code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/vicente-r-junior/notion-life-review-os" rel="noopener noreferrer"&gt;github.com/vicente-r-junior/notion-life-review-os&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Full setup instructions in the README.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Notion MCP
&lt;/h2&gt;

&lt;p&gt;Notion MCP is the backbone of the entire system. Every single interaction with Notion goes through it — no direct API calls anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading schema at startup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the app boots, it calls &lt;code&gt;API-retrieve-a-database&lt;/code&gt; and &lt;code&gt;API-retrieve-a-data-source&lt;/code&gt; for each of the 5 databases. The schemas get cached in Redis and injected directly into the GPT-4o system prompt — so the agent knows what fields exist, what types they are, and which ones are required, without any extra calls per message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the user confirms, the app calls &lt;code&gt;API-post-page&lt;/code&gt; for each item — daily log, tasks, projects, learnings. This part is pure deterministic Python, not an LLM. The write step is too important to leave non-deterministic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Querying data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For questions like "what tasks are due this week?", the agent uses &lt;code&gt;API-query-data-source&lt;/code&gt; with structured filters built from natural language. It resolves dates, applies status filters, and formats the answer for WhatsApp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updating schema dynamically&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the user asks to add a column — even via voice — the app calls &lt;code&gt;API-update-a-data-source&lt;/code&gt;. The Redis cache refreshes immediately and the system prompt is rebuilt. The new field is available on the next message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bulk updates&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For things like "set all tasks Who to Vicente", the app queries first, shows a confirmation with the affected records, then calls &lt;code&gt;API-patch-page&lt;/code&gt; for each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WhatsApp → Evolution API → FastAPI webhook
                               ↓
                     Intent classifier (GPT-4o-mini)
                               ↓
           ┌───────────────────┼──────────────────┐
           ↓                   ↓                  ↓
 Conversational agent     Query agent      Add column flow
      (GPT-4o)             (GPT-4o)        (GPT-4o-mini)
           ↓                   ↓                  ↓
      SAVE_PAYLOAD        Notion MCP         Notion MCP
           ↓               (query)          (update schema)
    User confirms
           ↓
     Notion Writer
     (pure Python)
           ↓
       Notion MCP
      (write pages)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One conversational agent instead of a pipeline.&lt;/strong&gt;&lt;br&gt;
I started with separate extractor, matcher, and confirmation agents. It was complex and fragile. A single GPT-4o call with Redis conversation history turned out to be simpler, faster, and much easier to debug. The agent holds the full context of the conversation and knows when it has enough information to produce a &lt;code&gt;SAVE_PAYLOAD&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The write step is never an LLM.&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;notion_writer&lt;/code&gt; is pure Python calling Notion MCP directly. Every property format handled explicitly. Giving an LLM direct write access to your Notion is asking for trouble.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema-aware prompts.&lt;/strong&gt;&lt;br&gt;
The agent knows your exact Notion schema at all times. Custom fields like &lt;em&gt;Who&lt;/em&gt;, &lt;em&gt;Priority&lt;/em&gt;, or &lt;em&gt;Energy&lt;/em&gt; are injected into the system prompt dynamically. If a field is marked required, the agent asks for it before saving — no partial records.&lt;/p&gt;
&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Python 3.12 + FastAPI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;OpenAI GPT-4o + Whisper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notion interface&lt;/td&gt;
&lt;td&gt;Notion MCP (&lt;code&gt;mcp/notion&lt;/code&gt; Docker image)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp bridge&lt;/td&gt;
&lt;td&gt;Evolution API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session + cache&lt;/td&gt;
&lt;td&gt;Redis 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;Docker Compose on Hostinger VPS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Clone and configure&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/vicente-r-junior/notion-life-review-os.git
&lt;span class="nb"&gt;cd &lt;/span&gt;notion-life-review-os
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Create 5 Notion databases&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inside a parent page called &lt;strong&gt;Life Review OS&lt;/strong&gt;, create:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Daily Logs&lt;/li&gt;
&lt;li&gt;Tasks&lt;/li&gt;
&lt;li&gt;Projects&lt;/li&gt;
&lt;li&gt;Learnings&lt;/li&gt;
&lt;li&gt;Weekly Reports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copy each database ID into &lt;code&gt;.env&lt;/code&gt;. Connect your Notion integration to the parent page — it propagates to all children automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Configure &lt;code&gt;.env&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OPENAI_API_KEY=sk-...
NOTION_API_KEY=secret_...
NOTION_DB_DAILY_LOGS=...
NOTION_DB_TASKS=...
NOTION_DB_PROJECTS=...
NOTION_DB_LEARNINGS=...
NOTION_DB_WEEKLY_REPORTS=...
MCP_AUTH_TOKEN=any-random-string
EVOLUTION_API_URL=http://your-evolution-api:8080
EVOLUTION_API_KEY=...
EVOLUTION_INSTANCE=your-instance-name
WHATSAPP_NUMBER=5511999999999
REDIS_URL=redis://app-redis:6379
TIMEZONE=America/Sao_Paulo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Start&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;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point your Evolution API webhook to &lt;code&gt;http://your-server:8000/webhook&lt;/code&gt; and you're live.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;MCP response parsing trips you up the first time.&lt;/strong&gt; Every Notion MCP response is SSE-wrapped JSON inside a &lt;code&gt;content&lt;/code&gt; array. Once you have the unwrapping pattern it's trivial — but it's not obvious when you first hit it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One agent beats a pipeline.&lt;/strong&gt; I built the multi-agent version first. Extractor, matcher, confirmation, writer — each doing one thing. It looked clean on paper and was a nightmare in practice. Replacing it with a single conversational GPT-4o call and Redis history was the best decision I made on this project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The write step should never be an LLM.&lt;/strong&gt; Flexible conversation on the way in, deterministic code on the way out. That's the pattern that worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt design is the real work.&lt;/strong&gt; Getting the agent to always include &lt;code&gt;SAVE_PAYLOAD&lt;/code&gt; when there's actionable content, never say "done" without confirming, correctly handle corrections mid-conversation — that's where most of the iteration went. The code was the easy part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis for everything.&lt;/strong&gt; Session state, schema cache, idempotency keys, conversation history — all in Redis with TTLs. No separate database needed. Cleanup is automatic.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>notionchallenge</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
