<?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: Mialy333 🎧ྀི</title>
    <description>The latest articles on DEV Community by Mialy333 🎧ྀི (@mialy333).</description>
    <link>https://dev.to/mialy333</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%2F1402385%2F13c22e43-dac2-48a2-9480-2492acaa2bb0.jpg</url>
      <title>DEV Community: Mialy333 🎧ྀི</title>
      <link>https://dev.to/mialy333</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mialy333"/>
    <language>en</language>
    <item>
      <title>My model gave my client an 82% risk of paying late. They paid 15 days early.</title>
      <dc:creator>Mialy333 🎧ྀི</dc:creator>
      <pubDate>Sun, 18 Oct 2026 15:02:10 +0000</pubDate>
      <link>https://dev.to/mialy333/my-model-gave-my-client-an-82-risk-of-paying-late-they-paid-15-days-early-35ih</link>
      <guid>https://dev.to/mialy333/my-model-gave-my-client-an-82-risk-of-paying-late-they-paid-15-days-early-35ih</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I'm a freelancer in Paris. When I send an invoice, I don't know whether the client will pay on time. I also never know how to word the reminder: too soft and I keep waiting, too firm and I risk the relationship.&lt;/p&gt;

&lt;p&gt;Late Pay Radar is a small app for freelancers that runs on your laptop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You add your invoices, as a CSV or as a text PDF exported from your invoicing tool.&lt;/li&gt;
&lt;li&gt;TabPFN, a tabular foundation model, ranks the unpaid ones by risk of late payment.&lt;/li&gt;
&lt;li&gt;You pick one. A local Gemma agent explains the risk and drafts a reminder in French or English: friendly before the due date, firmer after it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Client names, amounts and payment history stay on the laptop.&lt;/p&gt;

&lt;p&gt;The first user is me. I have one invoice on record: €1,725, issued on 30 June with 30-day terms, so due on 30 July.&lt;/p&gt;

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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/sccG2NXpBWw" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The video (52 s, no sound) uses a fake invoice so no real client name appears on screen. A PDF is imported, its fields are extracted and confirmed, the risk table appears, and Gemma writes the explanation and the reminder. The wait in the middle (about 13 s) is local inference on my MacBook. At the end, &lt;code&gt;ollama ps&lt;/code&gt; shows &lt;code&gt;gemma4:e2b&lt;/code&gt; running on the GPU. My real invoice and what the app said about it are described further down.&lt;/p&gt;

&lt;p&gt;There is no hosted version. Uploading your invoices to my server would defeat the point, so the app runs locally in 5 commands (see the README).&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/Mialy333" rel="noopener noreferrer"&gt;
        Mialy333
      &lt;/a&gt; / &lt;a href="https://github.com/Mialy333/late-pay-radar" rel="noopener noreferrer"&gt;
        late-pay-radar
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Predicts late invoice payments for freelancers, locally, with TabPFN + Gemma
    &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;late-pay-radar&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Ranks your open invoices by late-payment risk and drafts the right
reminder — entirely on your own machine (TabPFN + a local Gemma model via
Ollama). Demo video: &lt;a href="https://youtu.be/sccG2NXpBWw" rel="nofollow noopener noreferrer"&gt;https://youtu.be/sccG2NXpBWw&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quick start&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;git clone https://github.com/Mialy333/late-pay-radar &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-c1"&gt;cd&lt;/span&gt; late-pay-radar
uv sync
ollama pull gemma4:e2b
uv run python scripts/download_barnet.py &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; uv run python scripts/build_barnet_dataset.py
uv run streamlit run app.py&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Before that last command, you need a &lt;code&gt;TABPFN_TOKEN&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; — see the next section.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;TabPFN account (one-time)&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The risk model (TabPFN) needs a one-time license acceptance before it will
run locally, even for the demo data:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a free account at &lt;a href="https://ux.priorlabs.ai" rel="nofollow noopener noreferrer"&gt;https://ux.priorlabs.ai&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Accept the TabPFN-3.5 license (non-commercial use) on the Licenses tab.&lt;/li&gt;
&lt;li&gt;Copy your API key from &lt;a href="https://ux.priorlabs.ai/account" rel="nofollow noopener noreferrer"&gt;https://ux.priorlabs.ai/account&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cp .env.example .env&lt;/code&gt; and put the key in as &lt;code&gt;TABPFN_TOKEN=&amp;lt;your key&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is a one-time step: once accepted, it's cached locally and no further
account/network interaction is needed (see "Why local?"…&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/Mialy333/late-pay-radar" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The code is MIT. The public dataset comes from the London Borough of Barnet under the Open Government Licence v3.0.&lt;/p&gt;

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

&lt;p&gt;Stack: TabPFN-3.5 (local weights) for the risk score, Gemma (&lt;code&gt;gemma4:e2b&lt;/code&gt;) through Ollama for the text, Strands Agents in Python for the agent and its tool, Streamlit for the interface, pdfplumber to read PDFs locally.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding real data
&lt;/h3&gt;

&lt;p&gt;Freelancers don't publish their invoices, so I looked for public data. The London Borough of Barnet publishes late-payment reports: 1,561 supplier invoices with issue date, due date and actual payment date. Of the paid ones, 77% were paid more than 7 days late. These are suppliers waiting for a large client to pay them, which is the freelancer's situation.&lt;/p&gt;

&lt;p&gt;I only used information available on the day an invoice is issued: amount, payment terms, the client's past behaviour, month and weekday. Barnet's dispute, hold and "paid within terms" columns describe what happened after the invoice was sent, so using them would have given the model the answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  The backtest
&lt;/h3&gt;

&lt;p&gt;I trained on older invoices and tested on the 446 most recent ones.&lt;/p&gt;

&lt;p&gt;On the first run, TabPFN predicted "late" for almost everything. It recognised only 4 of the 123 invoices paid on time. On my synthetic test data it did the opposite and predicted "on time" every time. In both cases it was mostly repeating the most common answer.&lt;/p&gt;

&lt;p&gt;The problem was my evaluation. I added a baseline that always predicts the most common answer, measured ranking quality with ROC-AUC (a freelancer mainly needs to know which invoice to chase first), and chose the decision threshold on a validation slice instead of the test set.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Balanced accuracy&lt;/th&gt;
&lt;th&gt;ROC-AUC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Always the most common answer&lt;/td&gt;
&lt;td&gt;0.500&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Simple rule: "this client was late before"&lt;/td&gt;
&lt;td&gt;0.699&lt;/td&gt;
&lt;td&gt;0.687&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TabPFN, no tuning&lt;/td&gt;
&lt;td&gt;0.598&lt;/td&gt;
&lt;td&gt;0.688&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;TabPFN ranks invoices about as well as the simple rule, and makes worse yes/no decisions. It doesn't beat it.&lt;/p&gt;

&lt;p&gt;Then I split the test set by how much history we have on the client:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test subset&lt;/th&gt;
&lt;th&gt;TabPFN AUC&lt;/th&gt;
&lt;th&gt;Simple rule AUC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New clients, fewer than 3 past invoices (186)&lt;/td&gt;
&lt;td&gt;0.629&lt;/td&gt;
&lt;td&gt;0.584&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Known clients, 3 or more (260)&lt;/td&gt;
&lt;td&gt;0.766&lt;/td&gt;
&lt;td&gt;0.779&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The simple rule needs history. Without it, TabPFN can still use the amount and the payment terms. The gap is small and, on 186 invoices, not statistically solid. I still used it to design the product: new clients get the TabPFN score with a "⚠ low confidence" label, known clients get their own track record.&lt;/p&gt;

&lt;h3&gt;
  
  
  The agent
&lt;/h3&gt;

&lt;p&gt;The Strands agent has one tool, &lt;code&gt;get_invoice_risk&lt;/code&gt;. Gemma writes the explanation and the reminder. Most of the bugs I hit were fixed the same way, by moving the work that has one right answer out of the model and into code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Asked to find the riskiest invoices in a list of 78, Gemma picked from the middle. The ranking is now computed in code and shown as a table.&lt;/li&gt;
&lt;li&gt;It called an invoice that wasn't due yet "unpaid" and wrote a firm reminder. Due status, amounts and dates are now computed in code and passed to the model.&lt;/li&gt;
&lt;li&gt;It skipped the required note about where the training data comes from. The code now adds it every time.&lt;/li&gt;
&lt;li&gt;It sometimes left the amount or the date out of the reminder. A check now looks for both and asks for one rewrite. In testing, the first draft failed this check twice out of two, and the rewrite fixed both.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  PDF import
&lt;/h3&gt;

&lt;p&gt;My real invoice is a PDF, so in the morning I typed it into a CSV by hand. That's why PDF import was the first feature I asked for. The text is extracted locally, Gemma pulls out the fields as JSON, the code checks them, and you confirm them in an editable table before anything is scored. On my real PDF, Gemma read the due date correctly as 30 July. I was the one who had typed 31 July in my CSV.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;My own invoice.&lt;/strong&gt; Scored as if it had just been issued on 30 June, it got an 82% risk of late payment. My client paid 15 days early. TabPFN learned from UK public-sector suppliers, 77% of whom were paid late, and knew nothing about my client, my sector or my country. For a client with no history, the score mostly reflects that 77%. The app now shows this in a banner that the code adds, so the model can't leave it out: "Model trained on UK public-sector supplier invoices; transfer to other clients is uncertain."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A safety check that let a wrong answer through.&lt;/strong&gt; To cut the 13-second wait, I merged the explanation and the reminder into a single Gemma call. Latency dropped to 4 seconds, and the log said &lt;code&gt;Tools called: none&lt;/code&gt;. Gemma never looked up the invoice. It wrote "$1,500.00" and "October 15, 2026" instead of €3,250.00 and 10 May 2026. My check didn't catch it because it only ran when the tool had been called. I went back to two calls and changed the check: the app now looks up the invoice itself, always compares the reminder against it, and shows no reminder if the comparison fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My README.&lt;/strong&gt; I cloned the repo into an empty folder and followed only the README. It crashed. The raw Barnet files were excluded from git, &lt;code&gt;uv sync&lt;/code&gt; and &lt;code&gt;ollama pull&lt;/code&gt; weren't mentioned, and the TabPFN token was explained at the bottom although the page needs it to load. Barnet's server also refused the download from Python's default user agent. After the fixes, a fresh clone starts in 5 commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client data stays on the laptop.&lt;/strong&gt; TabPFN's weights are cached locally. I tested it with all network traffic blocked: scoring still works, same result, 0.29 s. I also turned off Streamlit's usage statistics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No cost per use.&lt;/strong&gt; There is no per-token bill. A tool priced at a few euros a month can't absorb API costs on every reminder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could check the claims myself.&lt;/strong&gt; The packages are open, so I could read the code and test that nothing is sent over the network instead of relying on a privacy policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The parts can be swapped.&lt;/strong&gt; Gemma, the agent framework and the scoring model are independent. The simple rule came close to TabPFN, so replacing the scorer is a real option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One limit matters for a side hustle: TabPFN-3.5's weights are under a non-commercial licence. That's fine for this prototype. Selling the tool would require Prior Labs' commercial licence or a different scorer, and I'd rather know that before the first paying customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first user
&lt;/h2&gt;

&lt;p&gt;I like how warm the reminders are. The 82% left me with a question I'm still asking: how can anyone really measure that, and through what lens? Still, it gives me an estimate of the risk. It helps me organise myself, saves me time and, when needed, helps me find common ground with a client.&lt;/p&gt;

&lt;p&gt;Part of the answer: 82% means that among the Barnet invoices that look like mine (30-day terms, similar amount, no client history), about 8 in 10 were paid late. It describes someone else's data, not my client. A single invoice also can't confirm or contradict a probability. That takes many invoices: of all those scored around 80%, about 80% should end up late. So the next step is to learn from my own payment history.&lt;/p&gt;

&lt;p&gt;What I asked for as a user: PDF import and warmer reminders that explain why paying on time helps and offer to discuss a schedule. Both were built this weekend. Next: photos and scanned invoices, and scores based on my own history instead of Barnet's.&lt;/p&gt;

&lt;p&gt;Would I pay for it? Yes, if it's secure and the automation saves me real work. My ceiling is €19.99 a month. At that price, a local model with no per-use cost is what makes the numbers work, once the TabPFN licence question is settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;Built with Claude Code over three saved sessions: the backtest, the agent, and the interface with the safety-check bug.&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;Late Pay Radar: UI, PDF import and a guardrail that failed open&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Build a one-page web interface for the invoice risk tool: pick a data source (a public demo dataset or your own invoices), see a ranked table, then pick one invoice and a language to get an explanation and a reminder from the local agent.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Built it as a single-page app. The ranking table is computed entirely in plain code, not by the language model, so it's deterministic and instant. The agent is only called once a specific invoice is selected, to explain its risk and draft a reminder. Also checked, before claiming the tool runs entirely locally, whether the underlying risk model or the web framework quietly phone home -- confirmed neither does once set up correctly, and disabled the one anonymous usage-telemetry setting the web framework has by default.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Let people also import their own invoices from a PDF, not just a spreadsheet. Extract the text locally, reject scanned image-only PDFs with a clear message, have the local model pull out the structured fields as data, validate them, and let the person review and correct everything in an editable table before anything gets scored. Nothing should ever be written to disk.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Built that pipeline: local text extraction first (scanned PDFs are caught and rejected immediately, before even trying the model), then the local model is asked to return the fields as structured data, which is checked in code (valid dates, positive amount, sane date ordering) with one retry if something's off and only the still-broken fields blanked out -- never the whole record. Everything stays in memory; confirmed by generating and testing with a fake sample invoice, never anything real.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Fix how the reminder reads: it should come from the freelancer who sent the invoice, to the client who owes the money -- first person, mentioning the invoice and amount and ending with a signature line. The explanation should also be in plain everyday words in whichever language was requested, not technical field names. And make sure the displayed amount is formatted the way each language actually writes money, not just by currency.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Fixed the perspective (the explanation was accidentally implying the user received the invoice rather than sent it -- corrected in both languages). Explanations now read in plain language, in whichever language was asked for. The displayed amount now formats itself to match the reply's language rather than always defaulting to one style, and the validator that checks a drafted reminder for the real figures was updated to recognize either language's way of writing the amount and the date, since the small local model doesn't always pick the one you'd expect.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Make the reminders warmer and more human -- greeting, a plain-language reason timely payment matters, an offer to help or discuss a schedule if timing is tough, and only mention a possible penalty when the invoice is genuinely overdue, never inventing a specific number. Then separately: fix the interface so switching the language doesn't lose or re-trigger anything, and make sure repeated interactions stay fast.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;Updated the reminder style and added a line-by-line check: while testing I found the small model doesn't always write an English date the way the French one does, so loosened the check to accept either common ordering rather than forcing one. For the interface, grouped the invoice/language/button controls so changing the language alone no longer reruns anything until the button is actually pressed, cached the underlying model connection and the computed table so repeat interactions are instant, and made results stick around per invoice-and-language combination instead of disappearing on unrelated screen updates. Also deliberately tried to reproduce the exact scenario the user was worried about (switching language right after confirming an import) using an automated interface-testing tool rather than guessing, and found no error either before or after the change.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Reorder the reminder so it leads with the concrete facts (invoice number, amount, due date) right after the greeting, before the explanation of why it matters. Separately, try combining the two model calls (explanation and reminder) into a single call to cut latency.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Reordered the reminder as asked. The single-call experiment did cut latency roughly in half when it worked -- but it made the small local model noticeably less reliable: in some runs it skipped actually looking up the real invoice data at all and just invented numbers in the reminder, and because the safety check only ran when the lookup had actually happened, a run where the model skipped it sailed through uncaught. That's a guardrail that fails OPEN -- it protects you only when things already went right, which defeats the point.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Revert to the two-call approach. And fix the guardrail properly: fetch the real invoice data in code BEFORE calling the model at all, and always check the drafted reminder against that real data, whether or not the model's own lookup happened. If it still doesn't check out after one retry, don't show it -- show a clear error instead. Same for a visibly broken explanation.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;Reverted to two separate calls. The real fix: the invoice's true numbers are now fetched directly in code, independent of anything the model does, and the reminder is always compared against those real numbers -- a run where the model never looks anything up now correctly fails the check instead of slipping through. If a reminder still doesn't check out after one retry, or an explanation comes back obviously broken (too short, or shouting in capitals), neither is shown -- a clear error is shown instead. Tested three times with a fake invoice: every reminder now correctly contains the real amount and date. One remaining honest finding: the explanation text itself sometimes came back as a flat 'I couldn't find that information' even though the real lookup clearly happened (the reminder had the right numbers) -- a model reliability quirk the length/formatting check doesn't catch, since the refusal is itself a normal-looking sentence. Flagged rather than hidden.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        12 of 12 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;


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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TabPFN (Prior Labs):&lt;/strong&gt; the risk scorer, backtested against baselines on real invoices, with separate handling for new clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma:&lt;/strong&gt; explanations, reminders and PDF field extraction, all running locally through Ollama.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I built a customer support agent on Amazon Bedrock AgentCore — and it refused to say my name</title>
      <dc:creator>Mialy333 🎧ྀི</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:30:00 +0000</pubDate>
      <link>https://dev.to/mialy333/i-built-a-customer-support-agent-on-amazon-bedrock-agentcore-and-it-refused-to-say-my-name-1n6l</link>
      <guid>https://dev.to/mialy333/i-built-a-customer-support-agent-on-amazon-bedrock-agentcore-and-it-refused-to-say-my-name-1n6l</guid>
      <description>&lt;p&gt;I spent ten years in asset management, most of it translating what portfolio managers and quants built into something clients could actually follow. Now I build agents. It turns out the job is weirdly similar: an agent is mostly a translation layer between a person asking a vague question and a set of systems that only understand precise ones.&lt;/p&gt;

&lt;p&gt;This is the second project of the &lt;strong&gt;AWS AI &amp;amp; ML Scholars — Future Agent Engineer&lt;/strong&gt; track: a customer support agent for a fictional e-commerce store, running on &lt;strong&gt;Amazon Bedrock AgentCore&lt;/strong&gt;. Here's how it's put together, the three things that tripped me up, and one bug that passed every test.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/Mialy333/agentcore-customer-support-agent" rel="noopener noreferrer"&gt;github.com/Mialy333/agentcore-customer-support-agent&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What the agent does
&lt;/h2&gt;

&lt;p&gt;One conversational entrypoint, six capabilities:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ask&lt;/th&gt;
&lt;th&gt;What answers it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Where's my order ORD-001?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;AgentCore Gateway&lt;/strong&gt; (MCP) → API Gateway → Lambda&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Refund my Kindle"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;AgentCore Gateway&lt;/strong&gt; (MCP) → Lambda&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"What does Platinum tier get me?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Bedrock Knowledge Base&lt;/strong&gt; (RAG)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Do you remember me?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;AgentCore Memory&lt;/strong&gt; via a Strands hook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"What's my discount with 4,250 points?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Code Interpreter&lt;/strong&gt; (sandboxed Python)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"What's on this web page?"&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Browser Tool&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It runs on &lt;strong&gt;AgentCore Runtime&lt;/strong&gt; (serverless, direct code deploy, Python 3.13), uses the &lt;strong&gt;Strands Agents SDK&lt;/strong&gt;, and reasons with &lt;strong&gt;Amazon Nova 2 Lite&lt;/strong&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%2Ff8njkucidfiaewsqgb1g.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%2Ff8njkucidfiaewsqgb1g.png" alt="Architecture" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole agent fits in a single &lt;code&gt;main.py&lt;/code&gt;. The entrypoint is short because each capability is a tool or a hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.entrypoint&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user_input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;actor_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anonymous&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="n"&gt;memory_hook&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MemoryHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;memory_client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MEMORY_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AgentCoreBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;REGION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;search_knowledge_base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;calculate_loyalty_discount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;gateway_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;streamable_http_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GATEWAY_URL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;gateway_client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;all_tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gateway_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_tools_sync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;all_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;hooks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;memory_hook&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SYSTEM_PROMPT&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;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine tools reach the model: three local ones, plus six discovered at runtime from the Gateway over MCP. The agent doesn't know (or care) that &lt;code&gt;get_order&lt;/code&gt; lives behind API Gateway while &lt;code&gt;initiate_refund&lt;/code&gt; is a bare Lambda — it just sees an MCP tool list.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Gateway: two very different backends, one MCP endpoint
&lt;/h2&gt;

&lt;p&gt;This was the part I found most "production-shaped". The two Lambdas have nothing in common:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;order-tracker&lt;/code&gt;&lt;/strong&gt; is a classic API Gateway proxy Lambda. It routes on &lt;code&gt;event["resource"]&lt;/code&gt; and &lt;code&gt;pathParameters&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;refund-processor&lt;/code&gt;&lt;/strong&gt; is called &lt;em&gt;directly&lt;/em&gt; by the Gateway. It never sees an HTTP request; it reads the tool name from the Lambda client context:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;raw_tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bedrockAgentCoreToolName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw_tool&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;___&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&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="c1"&gt;# "refund-processor___initiate_refund" → "initiate_refund"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AgentCore Gateway wraps both as targets of one MCP server. For the REST API, each operation ID (&lt;code&gt;get_order&lt;/code&gt;, &lt;code&gt;get_customer&lt;/code&gt;, &lt;code&gt;get_customer_orders&lt;/code&gt;) becomes a tool name. For the Lambda, you paste a JSON tool schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #1 — target names reject underscores.&lt;/strong&gt; I named my targets &lt;code&gt;order_tracker&lt;/code&gt; and &lt;code&gt;refund_processor&lt;/code&gt;, matching the Lambda files. The Gateway was created fine… and then both targets failed: the name must match &lt;code&gt;([0-9a-zA-Z][-]?){1,100}&lt;/code&gt;. Hyphens only. &lt;code&gt;order-tracker&lt;/code&gt; and &lt;code&gt;refund-processor&lt;/code&gt; went through.&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%2Fx41eftb8i68m1911rw1u.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%2Fx41eftb8i68m1911rw1u.png" alt="Gateway validation error" width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before writing any agent code, &lt;code&gt;npx @modelcontextprotocol/inspector&lt;/code&gt; against the Gateway URL is the fastest way to confirm all six tools are actually exposed.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. RAG: the boring part that just works
&lt;/h2&gt;

&lt;p&gt;The knowledge base is a single &lt;code&gt;product_catalog.txt&lt;/code&gt; (products, return windows, refund timelines, loyalty tiers) uploaded to S3 and synced into a &lt;strong&gt;Managed Knowledge Base&lt;/strong&gt;. The tool is a thin wrapper around the &lt;code&gt;Retrieve&lt;/code&gt; API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_bedrock_runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;knowledgeBaseId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;KB_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retrievalQuery&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retrievalResults&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
          &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;---&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&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="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Asked about the Platinum tier, the agent returned exactly what the catalog says — free same-day shipping, 15% discount, priority support — instead of whatever the model half-remembers about loyalty programs in general. That's the whole point of RAG: answers you can trace back to a document.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Memory: a hook, not a prompt rebuild
&lt;/h2&gt;

&lt;p&gt;AgentCore Memory is configured with two built-in strategies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic&lt;/strong&gt; → &lt;code&gt;cs_agent/{actorId}/facts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User preference&lt;/strong&gt; → &lt;code&gt;cs_agent/{actorId}/preferences&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent saves raw conversation events; AgentCore extracts durable facts and preferences from them in the background.&lt;/p&gt;

&lt;p&gt;On the agent side, I used a Strands &lt;code&gt;HookProvider&lt;/code&gt; with two callbacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;register_hooks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MessageAddedEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retrieve_customer_context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AfterInvocationEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save_support_interaction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before&lt;/strong&gt; the model sees a user message, the hook queries every namespace and prepends a &lt;code&gt;Customer Context:&lt;/code&gt; block to that message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After&lt;/strong&gt; the agent answers, it saves the (user, assistant) pair with &lt;code&gt;create_event&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design choice I like: the agent's reasoning loop never knows memory exists. It just receives a slightly richer message. Retrieval and saving each live in their own method, and tool-result messages are skipped so memory is only queried on real user turns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #2 — the agent refused to say my name
&lt;/h3&gt;

&lt;p&gt;The test: in session A, &lt;em&gt;"Hi, I am Jane. I prefer concise responses."&lt;/em&gt; Wait 30 seconds for extraction. In session B, &lt;em&gt;"Do you remember my name and communication preference?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;First run: it recalled "prefers concise responses"… and then declined to tell me my name, explaining it didn't have access to personal identification details for security reasons.&lt;/p&gt;

&lt;p&gt;Nothing in my code blocked it. The memory record was right there in the context. &lt;strong&gt;The model itself decided a name looked like sensitive data&lt;/strong&gt; and got cautious — about a name the customer had volunteered minutes earlier.&lt;/p&gt;

&lt;p&gt;The fix was a few sentences in the system prompt: the &lt;code&gt;Customer Context&lt;/code&gt; block contains facts the customer previously and voluntarily shared, and stating them back is expected behavior. After that:&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%2Foi9rwv3w7yzm5e0umq2v.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%2Foi9rwv3w7yzm5e0umq2v.png" alt="Memory recall across sessions" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It remembered Jane, the preference, &lt;em&gt;and&lt;/em&gt; her two recent orders. Lesson: guardrails baked into a foundation model can misfire in directions you never asked for, and sometimes prompt engineering means removing caution rather than adding it.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Code Interpreter: don't let an LLM do the arithmetic
&lt;/h2&gt;

&lt;p&gt;Loyalty math has just enough rules to go wrong: points redeem in 500-point blocks ($5 each), capped at 50% of the order, then a tier discount (Gold 10%, Platinum 15%) applies, then you earn points on what you paid.&lt;/p&gt;

&lt;p&gt;Instead of trusting the model, &lt;code&gt;calculate_loyalty_discount&lt;/code&gt; generates a small Python program and runs it in the &lt;strong&gt;AgentCore Code Interpreter&lt;/strong&gt; sandbox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;code_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REGION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;executeCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clearContext&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&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;Gold member, 4,250 points, $150 order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;4,000 points redeemable → &lt;strong&gt;$40&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;$110 × 10% Gold discount → &lt;strong&gt;$11&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Final total &lt;strong&gt;$99.00&lt;/strong&gt;, earns 99 points → &lt;strong&gt;349&lt;/strong&gt; points left&lt;/li&gt;
&lt;/ul&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%2Fdqzt0gkwwrylgj7n2lwc.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%2Fdqzt0gkwwrylgj7n2lwc.png" alt="Loyalty discount" width="800" height="477"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the sandbox is unavailable, the tool falls back to a tier-only calculation and says so in its output, rather than failing silently or letting the model guess.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Browser: one line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AgentCoreBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;REGION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AgentCore runs a managed headless browser; the agent got the live page title from udacity.com. The only real work was IAM.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploying (and Gotcha #3)
&lt;/h2&gt;

&lt;p&gt;The course intentionally uses the Python &lt;strong&gt;Bedrock AgentCore Starter Toolkit&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;uv run agentcore configure &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; main.py &lt;span class="nt"&gt;--name&lt;/span&gt; customer_support_agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--deployment-type&lt;/span&gt; direct_code_deploy &lt;span class="nt"&gt;--runtime&lt;/span&gt; PYTHON_3_13 &lt;span class="nt"&gt;--disable-memory&lt;/span&gt;
uv run agentcore deploy
uv run setup_permissions.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setup_permissions.py&lt;/code&gt; reads the resource IDs from &lt;code&gt;main.py&lt;/code&gt; &lt;em&gt;as text&lt;/em&gt; (with &lt;code&gt;ast&lt;/code&gt;, never importing the agent), checks the account and role match the deployment, then attaches one inline policy scoped to this Knowledge Base, this Memory and the managed browser. Least privilege, rerunnable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #3 — region drift.&lt;/strong&gt; One &lt;code&gt;configure&lt;/code&gt; run silently picked up &lt;code&gt;us-west-2&lt;/code&gt; while every resource lived in &lt;code&gt;us-east-1&lt;/code&gt;. Read the configuration summary before you deploy.&lt;/p&gt;

&lt;p&gt;Side note: every invoke now prints a notice that the Starter Toolkit is no longer supported in favor of the npm &lt;code&gt;@aws/agentcore&lt;/code&gt; CLI. Don't install both — they ship the same &lt;code&gt;agentcore&lt;/code&gt; binary.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bug my tests didn't catch
&lt;/h2&gt;

&lt;p&gt;All six scenarios passed. Then I re-read the refund output more carefully:&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%2F8pv7sfyz7z8gzxw180uy.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%2F8pv7sfyz7z8gzxw180uy.png" alt="Refund processing" width="799" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Refund ID ✅, &lt;code&gt;APPROVED&lt;/code&gt; ✅, "3–5 business days" ✅… and &lt;strong&gt;Refund Amount: $0&lt;/strong&gt;. Plus a confident "you don't need to return anything, this was a digital product" — about a physical Kindle.&lt;/p&gt;

&lt;p&gt;What happened: &lt;code&gt;amount&lt;/code&gt; is optional in the refund tool's schema, the Lambda defaults it to 0, and the model never called &lt;code&gt;get_order&lt;/code&gt; to look up the $139.99 total first. Then it filled the gap with a plausible story.&lt;/p&gt;

&lt;p&gt;The test checked for the &lt;em&gt;shape&lt;/em&gt; of the right answer, not for the answer being right. Fixes I'd ship in v2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;make &lt;code&gt;amount&lt;/code&gt; required in the schema, or better, have the Lambda look the amount up server-side so the model can't get it wrong;&lt;/li&gt;
&lt;li&gt;instruct the agent to read the order before acting on it;&lt;/li&gt;
&lt;li&gt;assert on values, not keywords, in the tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the kind of thing I'd rather find in a sandbox than in a customer's bank account.&lt;/p&gt;




&lt;h2&gt;
  
  
  If this went to production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A real authorizer on the Gateway.&lt;/strong&gt; The project uses the NONE inbound authorizer to stay focused on tool integration. That's fine for a sandbox and unacceptable for anything else: JWT/OAuth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries and timeouts&lt;/strong&gt; on the MCP connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tighter IAM&lt;/strong&gt; than the auto-created runtime execution role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost monitoring.&lt;/strong&gt; The vector store behind the Knowledge Base kept billing the whole time I was building, whether I was testing or not. Tear everything down when you're done (&lt;code&gt;agentcore destroy&lt;/code&gt;, then Gateway, Memory, KB, vector store, S3, API Gateway, Lambdas).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;The hardest part of AgentCore isn't the Python. It's getting Gateway, Memory, the Knowledge Base, the Code Interpreter and the Browser to agree with each other — same region, same protocol, same IAM role.&lt;/p&gt;

&lt;p&gt;And the most interesting failures weren't infrastructure failures. They were the model being &lt;em&gt;too&lt;/em&gt; careful with a name, and &lt;em&gt;not careful enough&lt;/em&gt; with a refund amount. Which is, honestly, the same lesson I learned explaining portfolios to clients: the risk is rarely in the system you can see; it's in the gap between what someone asked and what got executed.&lt;/p&gt;

&lt;p&gt;The full code, architecture diagrams, all six test screenshots and a 43-step setup walkthrough are in the repo:&lt;br&gt;
👉 &lt;a href="https://github.com/Mialy333/agentcore-customer-support-agent" rel="noopener noreferrer"&gt;github.com/Mialy333/agentcore-customer-support-agent&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Mialy, building at the intersection of AI agents and digital assets as @ellebuild. If you've hit other AgentCore gotchas, I'd love to hear them in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
    </item>
    <item>
      <title>I built an AI that sends me on autumn walks. Then I got sick.</title>
      <dc:creator>Mialy333 🎧ྀི</dc:creator>
      <pubDate>Sat, 10 Oct 2026 08:10:00 +0000</pubDate>
      <link>https://dev.to/mialy333/i-built-an-ai-that-sends-me-on-autumn-walks-then-i-got-sick-56o9</link>
      <guid>https://dev.to/mialy333/i-built-an-ai-that-sends-me-on-autumn-walks-then-i-got-sick-56o9</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&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%2F2zmvldpw223o4bqlj8sj.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%2F2zmvldpw223o4bqlj8sj.png" alt="Telegram message from the Autumn Walks bot, in English: weather advice with the driest hour of the afternoon, five trees with their street and autumn colour, total distance, walking time and a Google Maps link" width="652" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every morning, a small bot on my laptop picks five trees in Paris that are changing colour, checks the weather, and sends me a walk on Telegram. Reading it takes about 20 seconds. The walk takes about an hour. That ratio is the whole idea.&lt;/p&gt;

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

&lt;p&gt;Autumn is my favourite season. I'm fascinated by the colours, and there's a particular atmosphere to it, something warm. I love walking through the streets of Paris with a bit of music in my ears, just feeling those vibes. It's also the season of soups and comfort food, and of going out for a hot chocolate in a coffee shop with fogged-up windows, with the people you love.&lt;/p&gt;

&lt;p&gt;So this week, I built something to make sure I don't spend this autumn behind a screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autumn Walks&lt;/strong&gt; is a Telegram bot that sends me one walk every morning at 8:30:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a line of weather advice (umbrella or not, the driest hour of the afternoon)&lt;/li&gt;
&lt;li&gt;3 to 5 trees that turn red, orange or gold in October, with their street and their autumn colour&lt;/li&gt;
&lt;li&gt;the total distance, an honest walking time, and a Google Maps link for the loop&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It never sends the same streets two days in a row. It works in French and English. It is for one person: me.&lt;/p&gt;

&lt;p&gt;The trees come from Paris open data. The city publishes every tree it manages: 220,174 of them. I kept 2,065: the species with strong autumn colour (ginkgo, liquidambar, Persian ironwood, maple, oak) in five central arrondissements, and only trees you can actually walk up to: streets, public gardens, cemeteries. No school yards. (An early version sent me to a tree inside a nursery.)&lt;/p&gt;

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

&lt;p&gt;Here's what reached my phone on Thursday morning, in French, with nobody touching the laptop:&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%2Fefd677svk8hvmx4uy49y.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%2Fefd677svk8hvmx4uy49y.png" alt="Telegram message from the Autumn Walks bot, in French: weather advice, five trees with their street and autumn colour, total distance 3.9 km, walking time 65 minutes, and a Google Maps link" width="670" height="882"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In English:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ol&gt;
&lt;li&gt;Oak, 73 rue de Provence, copper brown&lt;/li&gt;
&lt;li&gt;Persian ironwood, rue Milton, red to orange&lt;/li&gt;
&lt;li&gt;Liquidambar, 3 rue de la Jussienne, red to purple&lt;/li&gt;
&lt;li&gt;Liquidambar, Jardin Nelson Mandela, red to purple&lt;/li&gt;
&lt;li&gt;Maple, 8 rue de la Banque, red or orange&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total distance: 3.9 km, walking time: 65 minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The part I didn't plan
&lt;/h3&gt;

&lt;p&gt;On Thursday I got sick. Properly sick. I didn't take a single one of these walks.&lt;/p&gt;

&lt;p&gt;It frustrated me a lot, because when someone gives me a challenge, I love seeing it through to the end. But my health comes first.&lt;/p&gt;

&lt;p&gt;On Friday, the bot went silent. The log had no entry at all. My Mac had been shut down overnight, and macOS &lt;code&gt;launchd&lt;/code&gt; catches up on a job missed during sleep, but not one missed while the machine is off. The bot built to get me outside was stuck inside with me.&lt;/p&gt;

&lt;p&gt;So this post is about a tool I built and tested, that ran on its own, and that I haven't used yet. The first walk is waiting for me when I'm back on my feet.&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/Mialy333" rel="noopener noreferrer"&gt;
        Mialy333
      &lt;/a&gt; / &lt;a href="https://github.com/Mialy333/autumn-walks" rel="noopener noreferrer"&gt;
        autumn-walks
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Plan short autumn walks past colourful Paris trees with a local LLM agent (Strands + Gemma 4 E2B on Ollama)
    &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;autumn-walks&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Ask "Where should I go for a walk this fall?" and get a short message, ready to send, inviting you on an autumn walk in Paris past trees with strong autumn colour (ginkgo, sweetgum, Persian ironwood, maple, oak). It runs on a small local model.&lt;/p&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/Mialy333/autumn-walks/docs/images/telegram-fr.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FMialy333%2Fautumn-walks%2FHEAD%2Fdocs%2Fimages%2Ftelegram-fr.png" alt="Morning walk message in French on Telegram: weather advice, five trees with their October colours, distance, walking time and a Google Maps link" width="45%"&gt;&lt;/a&gt;
  &amp;nbsp;
  &lt;a rel="noopener noreferrer" href="https://github.com/Mialy333/autumn-walks/docs/images/telegram-en.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FMialy333%2Fautumn-walks%2FHEAD%2Fdocs%2Fimages%2Ftelegram-en.png" alt="The same kind of walk message in English on Telegram, with a different route through five trees" width="45%"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design principle: the LLM writes, the code does the geography.&lt;/strong&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;div class="js-render-enrichment-target"&gt;
    &lt;div class="render-plaintext-hidden"&gt;
      &lt;pre&gt;flowchart LR
    Q["Question&amp;lt;br/&amp;gt;or daily 8:30 job"] --&amp;gt; A["Strands agent&amp;lt;br/&amp;gt;Gemma 4 E2B via Ollama"]
    A --&amp;gt; W["get_weather&amp;lt;br/&amp;gt;Open-Meteo"]
    A --&amp;gt; F["find_autumn_trees&amp;lt;br/&amp;gt;SQLite: Paris trees"]
    A --&amp;gt; B["build_walk&amp;lt;br/&amp;gt;Python: stops, distances, Maps link"]
    H[("Walk history&amp;lt;br/&amp;gt;SQLite")] -- "skip recent streets" --&amp;gt; B
    W --&amp;gt; A
    F --&amp;gt; A
    B --&amp;gt; A
    A --&amp;gt; M["Message&amp;lt;br/&amp;gt;checked before sending"]
    M --&amp;gt; T["Telegram"]
    M -- "record walk" --&amp;gt; H
&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;span class="js-render-enrichment-loader d-flex flex-justify-center flex-items-center width-full"&gt;
    &lt;span&gt;
      &lt;span class="sr-only"&gt;Loading&lt;/span&gt;
&lt;/span&gt;
  &lt;/span&gt;


&lt;p&gt;A &lt;a href="https://strandsagents.com" rel="nofollow noopener noreferrer"&gt;Strands Agents&lt;/a&gt; agent running &lt;strong&gt;Gemma 4 E2B&lt;/strong&gt; on &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt; uses three tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;get_weather&lt;/code&gt;: this afternoon's temperature, rain probability and wind from &lt;a href="https://open-meteo.com" rel="nofollow noopener noreferrer"&gt;Open-Meteo&lt;/a&gt; (no API key), plus whether to take…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Mialy333/autumn-walks" 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;p&gt;&lt;strong&gt;Stack, all open and all local except two HTTP calls:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model:&lt;/strong&gt; Gemma 4 E2B, running locally with Ollama&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent framework:&lt;/strong&gt; Strands Agents (Python)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; Paris open data "Les arbres" (ODbL), filtered once into SQLite&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weather:&lt;/strong&gt; Open-Meteo (free, no API key)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery:&lt;/strong&gt; a Telegram bot, triggered by a &lt;code&gt;launchd&lt;/code&gt; job every morning&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1: check that a small model can call tools at all
&lt;/h3&gt;

&lt;p&gt;Before writing anything else, I ran a spike: Gemma 4 E2B, one fake weather tool, one question, three runs. It called the tool correctly 3 times out of 3, in 3 to 7 seconds. That was the main risk of the week, and it took an evening to clear.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: the design principle that made it work
&lt;/h3&gt;

&lt;p&gt;My first agent let Gemma do everything: search the trees, pick the stops, compute the distances. It failed in instructive ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Without a system prompt, it called no tools at all&lt;/strong&gt;, in 3 runs out of 3, and said it couldn't plan routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When it picked the stops itself&lt;/strong&gt;, it chose far-apart rare trees and produced 4.8 to 5.5 km loops for a 40-minute walk, retrying up to 4 times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It invented places.&lt;/strong&gt; One walk included the Jardin du Luxembourg, which is not in any of the arrondissements I loaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It got numbers wrong&lt;/strong&gt;: it called a 1.2 km loop "about 3 km" and mislabelled distances.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I split the work:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The LLM writes. The code does the geography.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Python now searches the trees, picks the best set of 3 to 5 stops that fits the distance limit (most species first, then the shortest loop), computes every distance and the walking time (straight-line distance × 1.3 for real streets), and builds the Maps link. Tree names and autumn colours come from a fixed dictionary. Gemma reads all of that and writes a short, friendly message in French or English. It never invents a tree, a colour or a number.&lt;/p&gt;

&lt;p&gt;Before a message is sent, the code checks it: the walk was actually built, the exact link is present, the start point is right, the wind is mentioned, no street repeats from the last two walks, under 150 words. If a check fails, the agent is told why and tries again, at most twice. Every retry reason is logged.&lt;/p&gt;

&lt;p&gt;Current results: 11 checks out of 11 on every test run, 15 to 21 seconds per walk on a laptop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: memory
&lt;/h3&gt;

&lt;p&gt;The first daily version sent the same walk every day: same start, same trees. A small SQLite history fixed it. The agent now skips the &lt;strong&gt;streets&lt;/strong&gt; of the last two walks. (Skipping only the trees wasn't enough: it produced the same route with the tree next door.) The history stays on my machine and never goes to GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing to run.&lt;/strong&gt; Inference is local. The only network calls are the weather and the Telegram message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My location and habits stay mine.&lt;/strong&gt; A hosted model would receive my starting point and my walking history every single day. Here they never leave the laptop. To be precise: the Telegram message itself goes through Telegram's servers, and the weather call sends the coordinates of the Opéra, a public landmark I chose on purpose rather than my home.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data is open too.&lt;/strong&gt; The trees are Paris open data under ODbL. Any city that publishes its trees could get the same bot by swapping one file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could see and change everything.&lt;/strong&gt; When the agent misbehaved, I didn't have to guess what a black-box API was doing. I moved the geography out of the model and into code I can test. Swapping the model is one line of config.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Broke
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bot went silent on the day I got sick&lt;/strong&gt; (see above): a shut-down Mac doesn't run missed &lt;code&gt;launchd&lt;/code&gt; jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My agent session uploads were blocked.&lt;/strong&gt; Saving the coding sessions to DEV with DevRelay hit a Cloudflare 403 whenever the transcript contained code. I published lighter versions without code; the code is in the repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My Gmail address was in my first commit&lt;/strong&gt;, both as the commit author and in &lt;code&gt;pyproject.toml&lt;/code&gt;. Caught before the first push, fixed with a squash and GitHub's noreply address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Maps link was cut in half in Telegram&lt;/strong&gt;, because of the &lt;code&gt;|&lt;/code&gt; separators between waypoints. Encoding them as &lt;code&gt;%7C&lt;/code&gt; fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-Meteo returned 503 "overloaded"&lt;/strong&gt; while I was building, so the weather tool now retries, and tells the agent honestly when the weather is unavailable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Take the walks. All of them.&lt;/li&gt;
&lt;li&gt;Let friends subscribe, each with their own language and starting point.&lt;/li&gt;
&lt;li&gt;Make the colour depend on the date: "starting to turn" in early October, "golden" later on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Want your own?
&lt;/h2&gt;

&lt;p&gt;There's no public bot to message: it runs on one laptop for one person. But the repo is built to be adapted: your start point, your language, your send time. If your city publishes its trees as open data, tell me in the comments. I'd love to know where this could go next.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;The whole build was done with a coding agent in my terminal. Here's the session where the bot got its memory and its Telegram voice:&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;Autumn walks: walk memory and a Telegram morning message&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Keep the 4 km limit. Step 4a: give the agent a memory. Save each walk (date, start, trees, loop length) and leave out the trees of the last 2 walks, then plan 3 walks in a row and show they differ. Step 4b: Telegram. I created a bot and put its token in an environment file; never print that file, make sure it is ignored by git, find my chat id and save it there. Add a command that plans a walk and sends it to my chat, schedule it every morning at 8:30 without turning it on yourself, and send one real message now.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Check the secrets file without printing it&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ Check the secrets file without printing it&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Never committed, but not ignored by git yet. Added it to the ignore list. Only the variable names were ever shown.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Plan 3 walks in a row, leaving out the trees of the last 2 walks&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ Plan 3 walks in a row, leaving out the trees of the last 2 walks&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;All checks pass, but the route is the same each time: the same four streets, just with the neighbouring tree.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Leaving out tree ids changes nothing a walker would notice. The next walk now skips the streets and parks of the last 2 walks instead.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Plan 3 walks in a row again&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ Plan 3 walks in a row again&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Three different routes with no shared street, 3.5 to 4 km each, all 11 checks passing. One run needed a retry because the model skipped the walk-building step; the send check caught it and the reason was logged.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The walk history lives in its own small database that is not committed, so the tree data stays shareable and the history stays private. A walk is only recorded after it is actually sent, and tests use a throwaway history.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Look up the chat id from the bot's updates&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ Look up the chat id from the bot's updates&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;No messages found. The token worked and no webhook was set, so the message had probably gone elsewhere or expired.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The daily job was prepared but not turned on, from a template so the repository holds no personal paths. Everything was paused until the chat id was available.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I sent the bot two new messages. Try again, and if nothing comes back, check whether an earlier call acknowledged my messages. Then send the real walk, commit and push.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Look up the chat id again&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ Look up the chat id again&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Found, from a private chat, and saved without being printed. No earlier call had acknowledged the messages.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Send the real walk&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ Send the real walk&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Sent after one logged retry: a 3.5 km, 55-minute loop from Opéra Garnier, recorded as the first walk in the history.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Scan the changes for the token, the chat id and personal data, then commit an...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ Scan the changes for the token, the chat id and personal data, then commit and push&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Nothing found. Pushed.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Turn on the morning job.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Loaded: it runs every day at 8:30, or on wake if the Mac was asleep. The first walk goes out tomorrow and will avoid today's streets.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Add a language option for a single run and an option to send without recording the walk, then send me one walk in English without recording it, for a screenshot.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Send one English walk without recording it&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ Send one English walk without recording it&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Sent: a 3.9 km loop through rue de Provence, rue Milton, rue de la Jussienne, Jardin Nelson Mandela and rue de la Banque. The history still had exactly one walk afterwards.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Takeaways: memory has to be about what the user experiences, which here means streets rather than tree ids. Secrets stay safe by never printing them, checking the ignore list before anything else, and scanning every commit against the real values. And a check before sending turns a small model's occasional slip into a logged retry instead of a wrong message on someone's phone.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        8 of 8 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;


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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma 4 E2B runs locally and is the agent at the core of the project.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>ai</category>
    </item>
    <item>
      <title>My support chatbot scored 0.92. It was also lying to customers.</title>
      <dc:creator>Mialy333 🎧ྀི</dc:creator>
      <pubDate>Thu, 08 Oct 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/mialy333/my-support-chatbot-scored-092-it-was-also-lying-to-customers-1p7o</link>
      <guid>https://dev.to/mialy333/my-support-chatbot-scored-092-it-was-also-lying-to-customers-1p7o</guid>
      <description>&lt;p&gt;Twelve of my thirteen test prompts scored a perfect 1.00. Two independent evaluation runs agreed: &lt;strong&gt;0.92 correctness&lt;/strong&gt;. No message routed to the wrong place, both prompt-injection attempts refused.&lt;/p&gt;

&lt;p&gt;And yet, in one conversation, my chatbot told a customer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I have filed a bug report with ticket ID TIX-345678."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That ticket did not exist. Nothing had been written to the database. The customer would have walked away believing their bug was logged.&lt;/p&gt;

&lt;p&gt;This is the story of how I built that chatbot, why the score couldn't see the problem, and what I check now instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  The project
&lt;/h2&gt;

&lt;p&gt;I'm going through the &lt;strong&gt;Udacity × AWS Agent Engineer Nanodegree&lt;/strong&gt;. The first project: a customer-support chatbot for a fictional online shop that handles three kinds of messages.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bug reports&lt;/strong&gt;: collect a description, steps to reproduce, and the environment, possibly over several turns, then file a ticket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform questions&lt;/strong&gt; (orders, shipping, returns, payments): answer &lt;em&gt;only&lt;/em&gt; from the shop's FAQ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything else&lt;/strong&gt;: politely hand off to a human support line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One twist: Bedrock &lt;em&gt;Agents Classic&lt;/em&gt;, which the course was originally designed around, closed to new customers on 30 July 2026. So the project runs on its successor, the &lt;strong&gt;Amazon Bedrock AgentCore managed harness&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The other twist, and the actual point of the exercise: &lt;strong&gt;there's no classifier and no routing node.&lt;/strong&gt; All of the routing, information-gathering, and grounding behaviour lives in a single system prompt.&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;Customer ──► chat.py ──invoke_harness──► AgentCore managed harness  ◄──► Amazon Nova Pro
                                              │  (system_prompt.txt + FAQ)     temp 0, topK 1
                                              │
                                    tool call: bugreports___create_bug_report
                                              ▼
                                       AgentCore Gateway (MCP, IAM auth)
                                              ▼
                                      Lambda create_bug_report ──PutItem──► DynamoDB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Harness&lt;/strong&gt;: AgentCore runs the agent loop: model calls, session state, tool execution. I supply the prompt; the FAQ is injected at a &lt;code&gt;{{FAQ}}&lt;/code&gt; placeholder when the harness is created.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway&lt;/strong&gt;: exposes a Lambda as an MCP tool. The model sees it as &lt;code&gt;&amp;lt;targetName&amp;gt;___&amp;lt;toolName&amp;gt;&lt;/code&gt;, three underscores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda + DynamoDB&lt;/strong&gt;: validates that all three fields are non-empty, writes the ticket, returns a UUID &lt;code&gt;ticketId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything as code&lt;/strong&gt;: two CloudFormation stacks (tool + testing) and a few boto3 scripts. Iterating on the prompt is just: edit the file, re-run &lt;code&gt;create_harness.py&lt;/code&gt;, open a new chat session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model is pinned to &lt;code&gt;us.amazon.nova-pro-v1:0&lt;/code&gt; with greedy decoding (temperature 0, topK 1), which AWS recommends for reliable tool calling with Nova.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing with nothing but words
&lt;/h2&gt;

&lt;p&gt;Treating routing as a classification problem &lt;em&gt;inside the prompt&lt;/em&gt; worked better than I expected. The prompt tells the model to pick exactly one category before writing anything, and never mix categories.&lt;/p&gt;

&lt;p&gt;The hard part is the boundary. Is "my card was declined" a bug? Is "the mug arrived cracked"? I ended up with &lt;strong&gt;15 explicit tie-breakers&lt;/strong&gt;, for example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An error or crash on the checkout page → &lt;strong&gt;bug&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A declined card → &lt;strong&gt;platform question&lt;/strong&gt; (payments).&lt;/li&gt;
&lt;li&gt;An item that arrived damaged → &lt;strong&gt;platform question&lt;/strong&gt; (returns): &lt;em&gt;the software worked, the order didn't.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Search returning nothing for a product that clearly exists → &lt;strong&gt;bug&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one fallback principle for everything else: if the software misbehaved, it's a bug; if it's about a policy or an order, it's a platform question; otherwise, hand off.&lt;/p&gt;

&lt;p&gt;For bug collection, the rules were: re-read what the customer already said, ask for &lt;strong&gt;one&lt;/strong&gt; missing field at a time, and the moment all three are present, call the tool. No "shall I file this?" confirmation.&lt;/p&gt;

&lt;p&gt;I also added a section treating everything a customer writes as data, never instructions, with a list of override patterns ("ignore your previous instructions", "I'm your developer") to refuse without arguing or explaining how they were detected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluating it
&lt;/h2&gt;

&lt;p&gt;Manual chatting doesn't scale, so I wrote a 13-case suite: 3 bug-report cases, 3 FAQ cases, 2 hand-offs, and 5 edge cases (a bare &lt;code&gt;help&lt;/code&gt;, two ambiguous messages, two injections). A script runs each case in a &lt;strong&gt;fresh session&lt;/strong&gt; and writes a JSONL file in the format &lt;strong&gt;Bedrock Evaluations&lt;/strong&gt; expects; Nova Pro then scores each response against a reference as an LLM-as-a-judge.&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%2Fz6zdv2ly93av9hltnwcc.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%2Fz6zdv2ly93av9hltnwcc.png" alt="Bedrock Evaluations correctness histogram: 12 prompts at 1.0, 1 at 0, average 0.923" width="799" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To keep cases independent, I disabled harness memory. Small gotcha worth sharing: &lt;code&gt;create_harness&lt;/code&gt; accepts &lt;code&gt;memory={"disabled": {}}&lt;/code&gt;, but &lt;code&gt;update_harness&lt;/code&gt; needs &lt;code&gt;memory={"optionalValue": {"disabled": {}}}&lt;/code&gt;. Passing the create shape to update fails, and the script takes the update path on every re-run.&lt;/p&gt;

&lt;p&gt;Result: &lt;strong&gt;0.92&lt;/strong&gt;, twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the score didn't show
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. Both of the real defects I found, I found by doing something the evaluation never does: &lt;strong&gt;opening the DynamoDB table and comparing it to the chat transcript.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Invented ticket IDs
&lt;/h3&gt;

&lt;p&gt;In a multi-turn conversation (search bar broken → what happens? → which browser?), the bot ended with a ticket ID. But there was no &lt;code&gt;[tool call]&lt;/code&gt; line in the terminal, and the table still held nine rows, not ten.&lt;/p&gt;

&lt;p&gt;Across attempts it produced &lt;code&gt;#12345&lt;/code&gt;, &lt;code&gt;TICKET1234&lt;/code&gt;, &lt;code&gt;TIX-345678&lt;/code&gt;. Placeholder-shaped strings, never a UUID.&lt;/p&gt;

&lt;p&gt;My prompt already said "never fabricate information". That clearly wasn't specific enough. I added an explicit rule: &lt;strong&gt;the only ticket ID you may give is the exact &lt;code&gt;ticketId&lt;/code&gt; string returned by a successful call; if you didn't receive one, the report hasn't been filed, so say so.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Junk written into the ticket
&lt;/h3&gt;

&lt;p&gt;The one prompt that scored 0.00 in both runs: &lt;em&gt;"The app keeps logging me out. I'm using Chrome on Windows 11."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In the chat, the response looked fine: thanks, here's your real ticket ID. But in the table, &lt;code&gt;stepsToReproduce&lt;/code&gt; contained either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"Please provide specific actions or scenarios that lead to the app logging you out."&lt;/code&gt; (the bot's &lt;strong&gt;own clarifying question&lt;/strong&gt;, saved as if the customer had said it), or&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"Using the app."&lt;/code&gt; (filler)&lt;/li&gt;
&lt;/ul&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%2Fif3r1gjlyvzweezhtbep.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%2Fif3r1gjlyvzweezhtbep.png" alt="DynamoDB scan showing a healthy ticket next to tickets with junk in stepsToReproduce" width="799" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cause was a tension in my own prompt. To stop the bot from badgering customers, I'd made the steps rule lenient: &lt;em&gt;"It crashes when I click Pay" already counts.&lt;/em&gt; That works when the fault and the trigger are separate. It breaks when they're the same sentence. "Keeps logging me out" reads as both, so the model decided the field was satisfied and filled it with something rather than leave it empty.&lt;/p&gt;

&lt;p&gt;The fix isn't removing the leniency (that prevents a worse failure). It's requiring steps that are distinct from the description, and forbidding the assistant's own words in any field. I deliberately didn't apply it mid-project: changing a central rule after two scored runs would have broken the comparison between them.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Two smaller ones
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exploratory tool calls.&lt;/strong&gt; The model sometimes called &lt;code&gt;create_bug_report&lt;/code&gt; with a field missing, &lt;em&gt;on purpose&lt;/em&gt;, so the Lambda's validation error would tell it what to ask. Customer-visible behaviour was fine; the mechanism was wrong. New rule: a failed tool call is never a way to discover a missing field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning leaks.&lt;/strong&gt; Nova Pro liked to open replies with &lt;code&gt;&amp;lt;thinking&amp;gt;…&amp;lt;/thinking&amp;gt;&lt;/code&gt;. Moving the "never show your reasoning" rule to the very top of the prompt reduced it a lot. It didn't eliminate it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. For tool-using agents, assert on side effects, not on text.&lt;/strong&gt;&lt;br&gt;
A correctness judge reads the response. A confident, helpful-sounding response wrapped around an empty or invented field scores well. The defects that mattered most were invisible to the metric and obvious in the database. Next time, the test suite checks the stored row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Single-turn evals don't test multi-turn behaviour.&lt;/strong&gt;&lt;br&gt;
My two most important fixes governed multi-turn collection, and all 13 eval prompts were single-turn. Run 2's identical 0.92 told me the fixes cost nothing and the score was reproducible. It didn't tell me they worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Long prompts degrade unevenly.&lt;/strong&gt;&lt;br&gt;
Even with greedy decoding, rules buried in the middle of a long prompt were followed in some sessions and not others. Multi-turn ticket filing with Nova Pro stayed unreliable. The next iteration is a shorter, restructured prompt, not more rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A few AgentCore details that cost me time:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gateway target names: letters, digits, underscores only. A dash breaks Nova tool calling with &lt;em&gt;"Model produced invalid sequence as part of ToolUse"&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;The Gateway passes tool arguments &lt;strong&gt;directly&lt;/strong&gt; as the Lambda event. No Agents Classic &lt;code&gt;parameters&lt;/code&gt; envelope. The tool name arrives in &lt;code&gt;context.client_context.custom["bedrockAgentCoreToolName"]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pin the model on the harness &lt;em&gt;and&lt;/em&gt; on each invoke; the default model needs a Marketplace subscription.&lt;/li&gt;
&lt;li&gt;Session IDs must be at least 33 characters.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The full code, prompt, test suite, transcripts and screenshots are on GitHub:&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://github.com/Mialy333/support-chatbot-bedrock-agentcore" rel="noopener noreferrer"&gt;Mialy333/support-chatbot-bedrock-agentcore&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're evaluating agents that write to anything (a database, a ticket system, an API), I'd love to hear how you test the side effects. That's the part I'm working on next.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Mialy, ex-asset-management, now building agents and digital-asset tooling. I write about it as @ellebuild.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
    </item>
    <item>
      <title>From Finance to AI Engineering: 6 lessons I learned building a Multi-Agent Support system on Amazon Bedrock AgentCore</title>
      <dc:creator>Mialy333 🎧ྀི</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:20:58 +0000</pubDate>
      <link>https://dev.to/mialy333/from-finance-to-ai-engineering-6-lessons-i-learned-building-a-multi-agent-support-system-on-amazon-2fm1</link>
      <guid>https://dev.to/mialy333/from-finance-to-ai-engineering-6-lessons-i-learned-building-a-multi-agent-support-system-on-amazon-2fm1</guid>
      <description>&lt;p&gt;After ten years in asset management, I started a full-stack developer path in 2024 and spent my time between blockchain and AI. Becoming an AI engineer felt out of reach. Then I joined the Udacity &lt;em&gt;AWS &amp;amp; AI Scholars — Future Agent Engineer&lt;/em&gt; Nanodegree, and its third project asked for something very concrete: build the AI layer of a customer support team.&lt;/p&gt;

&lt;p&gt;This post is the story of that project: what I built, what broke, and what it taught me.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The brief
&lt;/h2&gt;

&lt;p&gt;NovaMart is a fictional e-commerce company. Its support agents look up orders, check policies and draft answers by hand. The goal: a system that understands a request, gathers facts, applies company policy and replies, with no human in the loop, and with safety and observability built in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture: 5 agents, 1 shared record
&lt;/h2&gt;

&lt;p&gt;I built it with the &lt;strong&gt;Strands Agents SDK&lt;/strong&gt; and deployed it on &lt;strong&gt;Amazon Bedrock AgentCore Runtime&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an &lt;strong&gt;Orchestrator&lt;/strong&gt; (Claude Haiku 4.5, temperature 0) that only routes, and never answers itself;&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;Inventory agent&lt;/strong&gt; that reads orders and customers from DynamoDB and reports facts only;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;Refund agent&lt;/strong&gt; that decides return eligibility: 30 days for Standard customers, 60 for Premium;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;Policy agent&lt;/strong&gt; that runs three retriever sub-agents &lt;strong&gt;in parallel&lt;/strong&gt;, one per Bedrock Knowledge Base (returns, shipping, warranty);&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;Communication agent&lt;/strong&gt; that writes every final reply.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agents never talk to each other directly. Each one writes its findings into a shared DynamoDB record, the &lt;em&gt;WorkflowState&lt;/em&gt;, with optimistic locking: every write states which version it expects. A &lt;strong&gt;Bedrock Guardrail&lt;/strong&gt; sits on every model call, and every request becomes an &lt;strong&gt;X-Ray trace&lt;/strong&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%2F6wp61v51k5e1dt1ijxeh.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%2F6wp61v51k5e1dt1ijxeh.png" alt="X-Ray service map" width="800" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The automated test suite scored &lt;strong&gt;120/120&lt;/strong&gt;. The interesting part is what happened along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: a guardrail can block the right question
&lt;/h2&gt;

&lt;p&gt;The guardrail had to deny three topics: competitor products, legal threats and pricing negotiations. My first version blocked the brief's own test question: &lt;em&gt;"How much would 5 items at $29.99 be with a 10% discount?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Reading the guardrail's assessment told me exactly which policy fired: &lt;em&gt;Pricing negotiations&lt;/em&gt;. My topic definition mentioned discounts and calculations, and one of my examples ("Can you give me 30% off if I buy two?") was simply too close to a math question.&lt;/p&gt;

&lt;p&gt;Instead of guessing, I wrote a small tuning script. It updates only the guardrail's DRAFT, runs 14 test cases against it (three calculations that must pass, three negotiations that must be blocked, plus one case per other policy) and publishes a new version only when all 14 pass. The final definition targets &lt;em&gt;haggling&lt;/em&gt;, not prices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; treat a guardrail like code. It needs a test suite, and it should be tuned on a draft before it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: the prompt said "one at a time"; the framework did not
&lt;/h2&gt;

&lt;p&gt;A plain "Where is my order?" request produced a strange answer from the Refund agent: &lt;em&gt;"I don't have access to the order facts yet."&lt;/em&gt; The trace showed the Refund and Inventory agents running &lt;strong&gt;at the same time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The model had requested both tools in one turn, and Strands executes the tool calls of one turn &lt;strong&gt;concurrently&lt;/strong&gt; by default. The Refund agent read the WorkflowState before the Inventory agent had written to it. The optimistic lock even logged the conflict.&lt;/p&gt;

&lt;p&gt;The fix was one line, &lt;code&gt;tool_executor=SequentialToolExecutor()&lt;/code&gt;. That guarantees the order in code, so I no longer rely on the model to follow an instruction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if correctness depends on an order of operations, enforce it in code. A prompt is a request, not a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: attack your own system
&lt;/h2&gt;

&lt;p&gt;Once the system was deployed, I sent ten adversarial requests to the live runtime: a negotiation, a competitor comparison, a legal threat, a social security number, an insult, two prompt injections, and one request from customer CUST-002 asking for CUST-001's email and order history.&lt;/p&gt;

&lt;p&gt;The guardrail blocked all five attacks, each attributed to the right policy. The injections were contained: a fake "admin mode" did not get an out-of-window return approved, and no system prompt leaked.&lt;/p&gt;

&lt;p&gt;The last case looked fine too: the request was refused. But the &lt;strong&gt;X-Ray trace&lt;/strong&gt; told another story. It contained only the Orchestrator, with no session initialization and no Communication agent. The model had decided to answer by itself, breaking two of its own routing rules. And the refusal relied entirely on the model's judgment: nothing in the code stopped a tool from reading another customer's data.&lt;/p&gt;

&lt;p&gt;I fixed it at two levels, in code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;Strands hook&lt;/strong&gt; that runs the Communication agent whenever a request ends without it;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;customer isolation inside the DynamoDB tools&lt;/strong&gt;: the routing layer binds the session's customer to the worker agent, and the tools refuse any other customer ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I re-ran the same ten requests: 10/10, and the trace of the cross-customer case now shows the full, correct path.&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%2F5zfjb8oy3nv8mdsgmp2k.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%2F5zfjb8oy3nv8mdsgmp2k.png" alt="Trace after the fix" width="799" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; the answer looking right is not enough. The trace is where you see &lt;em&gt;how&lt;/em&gt; the system got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: defense in depth for actions that change data
&lt;/h2&gt;

&lt;p&gt;The Refund agent decides, but the tool that writes the refund checks again: order status, tier, return window, then a conditional DynamoDB write. Even if a prompt injection convinced the model, the database would not accept an ineligible return.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: memory is only useful if the right agent sees it
&lt;/h2&gt;

&lt;p&gt;Strands has no built-in DynamoDB session storage, so I wrote a small &lt;code&gt;SessionRepository&lt;/code&gt; on DynamoDB and plugged it into &lt;code&gt;RepositorySessionManager&lt;/code&gt;. My first demo proved that persistence worked: after a simulated restart, ten messages were restored. But the answer to "Which order did I ask about earlier?" was "I don't have a record of that."&lt;/p&gt;

&lt;p&gt;The orchestrator remembered; the agent that &lt;em&gt;writes the reply&lt;/em&gt; did not. The fix was to pass the conversation so far to the Communication agent, in code. After that, the restarted system correctly recalled ORD-27176 and the product name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 6: identity must come from the token, not the browser
&lt;/h2&gt;

&lt;p&gt;The last extension was a web front end with &lt;strong&gt;Amazon Cognito&lt;/strong&gt;. A Lambda proxy validates the user's access token with Cognito, reads the customer ID from the user's Cognito attribute, and calls the runtime. A user who edits the request to claim another customer ID is still served as themselves.&lt;/p&gt;

&lt;p&gt;I used a Lambda Function URL rather than API Gateway because a multi-agent request takes 20 to 50 seconds, beyond API Gateway's 30-second limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability, beyond the requirements
&lt;/h2&gt;

&lt;p&gt;A CloudWatch dashboard tracks invocations, average latency per agent and guardrail interventions. The metrics use the &lt;strong&gt;Embedded Metric Format&lt;/strong&gt;: structured log lines that CloudWatch turns into metrics, which needed no extra IAM permission in the lab account.&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%2Fs5g1idgesfmpg4elugtd.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%2Fs5g1idgesfmpg4elugtd.png" alt="Dashboard" width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One trace also showed me where the time goes: the three parallel knowledge-base lookups take about 0.8 seconds, while the retriever agents' own model calls take about 10. That is my next optimization target.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away
&lt;/h2&gt;

&lt;p&gt;Building agents is less about prompts than I expected, and more about engineering: ordering, isolation, verification, evidence. Every time I trusted a prompt to enforce a rule, a test or a trace showed me where it failed, and the fix belonged in code.&lt;/p&gt;

&lt;p&gt;And the finance background helped more than I thought. Ten years of explaining complex strategies clearly to clients turned out to be good training for documenting a system, its decisions and its limits.&lt;/p&gt;

&lt;p&gt;The full code, test outputs and screenshots are on GitHub: &lt;a href="https://github.com/Mialy333/novamart-multi-agent-support" rel="noopener noreferrer"&gt;Mialy333/novamart-multi-agent-support&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Mialy, building in AI and Web3 as @ellebuild. If you are working on agent systems, I'd love to compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
    </item>
    <item>
      <title>When everything feels urgent: I built my sister a tool that gives her just one next step</title>
      <dc:creator>Mialy333 🎧ྀི</dc:creator>
      <pubDate>Sun, 04 Oct 2026 22:44:41 +0000</pubDate>
      <link>https://dev.to/mialy333/when-everything-feels-urgent-i-built-my-sister-a-tool-that-gives-her-just-one-next-step-3hg7</link>
      <guid>https://dev.to/mialy333/when-everything-feels-urgent-i-built-my-sister-a-tool-that-gives-her-just-one-next-step-3hg7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6tzi4r2pkns7ibvswyvv.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6tzi4r2pkns7ibvswyvv.gif" alt="Demo of One step: a messy to-do list is typed into a text box, the app shows one task to do right now with a 'Still too big' button, three next tasks on pastel cards, and the line 'Everything else can wait.'" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://one-step-jypm.onrender.com" rel="noopener noreferrer"&gt;https://one-step-jypm.onrender.com&lt;/a&gt; · Chrome or Edge on a computer (WebGPU)&lt;/p&gt;

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

&lt;p&gt;My sister has a lot going on in her head at once: bills, a CV to redo, messages she hasn't answered, a trip to plan. Everything feels equally loud. When that happens, she doesn't pick the wrong task. She picks none, or she picks whatever feels most urgent, which is rarely what matters most.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;One step&lt;/strong&gt;. You dump everything on your mind into one box, in any order, no structure. A small open-weight model reads it and gives back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;one&lt;/strong&gt; thing to do right now, small enough to start in under 10 minutes,&lt;/li&gt;
&lt;li&gt;at most &lt;strong&gt;three&lt;/strong&gt; things for later, with anything that has a deadline first,&lt;/li&gt;
&lt;li&gt;and a single line: &lt;em&gt;"Everything else can wait."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the one thing still feels too big, there's a &lt;strong&gt;"Still too big"&lt;/strong&gt; button that breaks it into an even smaller first step.&lt;/p&gt;

&lt;p&gt;That's it. No accounts, no streaks, no reminders, no red badges. Everything about it is designed to lower the volume, not add to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What she said
&lt;/h3&gt;

&lt;p&gt;I handed it over on Sunday evening, on a video call. She opened the link in Chrome, typed her own list, and I didn't help. These are her words, translated from French:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's already great, honestly."&lt;/p&gt;

&lt;p&gt;"For prioritizing, it's really nice."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then she said the thing that made the whole weekend worth it: she tends to do what's &lt;em&gt;urgent&lt;/em&gt; before what's &lt;em&gt;important&lt;/em&gt;, and urgent isn't always important. Seeing just one thing on the screen helped her see the difference.&lt;/p&gt;

&lt;p&gt;She also immediately told me what she wanted next. More on that below, because it's the most useful feedback I got.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://one-step-jypm.onrender.com" rel="noopener noreferrer"&gt;https://one-step-jypm.onrender.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first visit downloads the model once (about 1.5 GB). After that it's cached, and every answer comes back in about a second. You can switch between French and English with the toggle at the top; she uses it in French.&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/Mialy333" rel="noopener noreferrer"&gt;
        Mialy333
      &lt;/a&gt; / &lt;a href="https://github.com/Mialy333/one-step" rel="noopener noreferrer"&gt;
        one-step
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      🌱 Hacktoberfest 2026 · DEV Challenge: turn a messy to-do list into one next step. Gemma 2 2B runs 100% in your browser (WebLLM + WebGPU), nothing leaves your device.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🌱 One step · Un pas&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;When everything feels urgent, get just one next step.&lt;/strong&gt;
Dump everything on your mind. An open-weight model running &lt;strong&gt;entirely in your browser&lt;/strong&gt; picks one thing to do right now.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://hacktoberfest.com" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/920513920a9018c851253d431ecf3e1479685a1a99a9acda432b09bb5316a6ad/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4861636b746f626572666573742d323032362d6632363934613f7374796c653d666f722d7468652d6261646765" alt="Hacktoberfest 2026"&gt;&lt;/a&gt;
&lt;a href="https://dev.to/mialy333/when-everything-feels-urgent-i-built-my-sister-a-tool-that-gives-her-just-one-next-step-3hg7" rel="nofollow"&gt;&lt;img src="https://camo.githubusercontent.com/7106f46d5ce778de9fb6aa381814f1d9b9057cc68a7c310418860a5288a99f3f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4445562532304368616c6c656e67652d4275696c64253230666f7225323061253230467269656e642d3061306130613f7374796c653d666f722d7468652d6261646765266c6f676f3d646576646f74746f" alt="DEV Weekend Challenge"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://ai.google.dev/gemma" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/364c14da6240ee3bdbc47aedeb3f556207e70868e5556f35680f5bb5e0ceb61a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6d6f64656c2d47656d6d612532303225323032422d343238354634" alt="Gemma 2 2B"&gt;&lt;/a&gt;
&lt;a href="https://github.com/mlc-ai/web-llm" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/070654f2bfa6c35927a3a68b83e831af2093fce29d0456f7531611a751cba984/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f696e666572656e63652d5765624c4c4d2d356638613636" alt="WebLLM"&gt;&lt;/a&gt;
&lt;a href="https://developer.mozilla.org/docs/Web/API/WebGPU_API" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/510932fd6b7db2feffeb142aec722c10857fa9dfcfde59a60c546af262e035c2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f72756e732532306f6e2d5765624750552d366234666262" alt="WebGPU"&gt;&lt;/a&gt;
&lt;a href="https://github.com/Mialy333/one-step#-privacy-by-architecture" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/c3d704f5c3d69a8cce0ca31251fb89cb2d6f32e65c0de8ce9720bdab97cd4598/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6261636b656e642d6e6f6e652d6c6967687467726579" alt="No backend"&gt;&lt;/a&gt;
&lt;a href="https://github.com/Mialy333/one-step/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/3921b6a636b7d3fc4db90f929b9047fb47fa4fdd6233a2b5fa60b827480fc2c5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d79656c6c6f77" alt="License: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://one-step-jypm.onrender.com" rel="nofollow noopener noreferrer"&gt;&lt;strong&gt;Live demo&lt;/strong&gt;&lt;/a&gt; · &lt;a href="https://dev.to/mialy333/when-everything-feels-urgent-i-built-my-sister-a-tool-that-gives-her-just-one-next-step-3hg7" rel="nofollow"&gt;&lt;strong&gt;Read the story on DEV&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/Mialy333/one-step/assets/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FMialy333%2Fone-step%2FHEAD%2Fassets%2Fdemo.gif" alt="Demo of One step: a messy to-do list is typed into a text box, the app shows one task to do right now with a 'Still too big' button, three next tasks on pastel cards, and the line 'Everything else can wait.'"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="markdown-alert markdown-alert-note"&gt;
&lt;p class="markdown-alert-title"&gt;Note&lt;/p&gt;
&lt;p&gt;Built for the &lt;strong&gt;Hacktoberfest 2026 DEV Weekend Challenge: &lt;em&gt;Build for a Friend&lt;/em&gt;&lt;/strong&gt; (MLH × DEV), whose prompt is to build something with open-source AI at its core. I built it in one evening for my sister, and she used it the same night.&lt;/p&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;💡 Why&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;When everything feels equally loud, it's easy to pick nothing, or to pick whatever feels most &lt;em&gt;urgent&lt;/em&gt; instead of what's &lt;em&gt;important&lt;/em&gt;. One step turns a messy brain dump into:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;one&lt;/strong&gt; action to do right now, small enough to start in under 10 minutes;&lt;/li&gt;
&lt;li&gt;at most &lt;strong&gt;three&lt;/strong&gt; next actions, with deadlines first;&lt;/li&gt;
&lt;li&gt;one fixed line: &lt;em&gt;"Everything else can wait."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the…&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/Mialy333/one-step" 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;p&gt;One static HTML file. No framework, no backend, no build step.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model:&lt;/strong&gt; Gemma 2 2B (&lt;code&gt;gemma-2-2b-it-q4f16_1-MLC&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inference:&lt;/strong&gt; &lt;a href="https://github.com/mlc-ai/web-llm" rel="noopener noreferrer"&gt;WebLLM&lt;/a&gt; on WebGPU, so the model runs on the GPU of whoever opens the page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output:&lt;/strong&gt; WebLLM's JSON mode with a schema, so the interface always gets the same shape back&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting:&lt;/strong&gt; a static site on Render&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why this model
&lt;/h3&gt;

&lt;p&gt;I first wanted the newest, smallest Gemma. But my sister lives far from me, so whatever I built had to work on her laptop from a link, with nothing to install, on the first try, tonight. Gemma 4 E2B runs in the browser too, but it's around 2.9 GB to download. Gemma 2 2B is officially prepared for WebLLM, about half the size, and good enough for this job. I picked the reliable option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measured, not guessed
&lt;/h3&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;My laptop&lt;/th&gt;
&lt;th&gt;Her laptop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model ready&lt;/td&gt;
&lt;td&gt;not measured&lt;/td&gt;
&lt;td&gt;20.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time per answer&lt;/td&gt;
&lt;td&gt;0.9 to 1.5 s&lt;/td&gt;
&lt;td&gt;4.5 s for the first, then 0.9 to 1.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;66 to 75 tokens/s&lt;/td&gt;
&lt;td&gt;30 to 38 tokens/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Her machine is about half as fast as mine, and it doesn't matter: one second is instant for a human.&lt;/p&gt;

&lt;h3&gt;
  
  
  What broke
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. The page sat on "Thinking…" forever.&lt;/strong&gt; Two bugs at once. My call to the model had no &lt;code&gt;try/catch&lt;/code&gt;, so errors went silently to the console. And I hadn't capped &lt;code&gt;max_tokens&lt;/code&gt;, so a small model in JSON mode could loop on whitespace for minutes. I added streaming, a token cap, and visible errors. Then the real error showed up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to initialize the grammar matcher for response format `json_object`:
Cannot pass non-string to std::string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WebLLM wants the JSON schema passed as a &lt;strong&gt;string&lt;/strong&gt;, not an object. One &lt;code&gt;JSON.stringify&lt;/code&gt; later, the model was &lt;em&gt;forced&lt;/em&gt; to return my exact shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The model was fast, and wrong.&lt;/strong&gt; My first version asked for three lists: now, later, and "can wait". On my first test it put 4 items in a list capped at 3, invented a task I never wrote, gave advice about someone's mother, and put the same message in both "do now" and "can wait". On the second test it dropped the only task with a deadline, and copied "do the dishes" from my &lt;em&gt;example&lt;/em&gt; into the answer.&lt;/p&gt;

&lt;p&gt;What fixed it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;strict rules in the prompt: only use tasks from the list, invent nothing, each task appears once;&lt;/li&gt;
&lt;li&gt;every action written as a short verb-first phrase, which also stopped it from mixing "you", "I" and formal French;&lt;/li&gt;
&lt;li&gt;deadlines first;&lt;/li&gt;
&lt;li&gt;an example with zero overlap with real lists, so there's nothing to copy;&lt;/li&gt;
&lt;li&gt;and the biggest one: &lt;strong&gt;I deleted the third list.&lt;/strong&gt; "Everything else can wait" is now fixed text in the interface. The model can't get it wrong because the model doesn't write it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small model, smaller job. That was the lesson.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A blank page.&lt;/strong&gt; When I added the language toggle, I moved all the text into JavaScript. But the script started with a static import of WebLLM from a CDN, and the browser runs nothing until that import finishes. So no text at all. I switched to a dynamic &lt;code&gt;import()&lt;/code&gt; that runs &lt;em&gt;after&lt;/em&gt; the text is on screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. "Still too big" has a floor.&lt;/strong&gt; On her laptop, she pressed it twice in a row: &lt;em&gt;Pay the bill&lt;/em&gt; → &lt;em&gt;Open the bill's page&lt;/em&gt; → &lt;em&gt;Open the browser&lt;/em&gt;. It made us both laugh. But honestly, for someone frozen in front of a task, "open the browser" might be exactly the right size.&lt;/p&gt;

&lt;h3&gt;
  
  
  What she asked for next
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tick a task and move to the next one.&lt;/strong&gt; She wants the satisfaction of checking it off. That's a reward, not organization.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;See everything she has already done.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add tasks, and see the ones that disappeared.&lt;/strong&gt; Some things she wrote weren't on screen anymore.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Number 3 is a direct consequence of my own design choice. Hiding everything except one task makes the screen calmer, but it also made her wonder where her tasks went. Calm versus trust is the real design problem here, and I only found it because a real person used it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Look at what she types into this box: bills, money, job worries, family. That's exactly the kind of text you shouldn't have to send to someone else's server to get help with.&lt;/p&gt;

&lt;p&gt;With an open-weight model running in the browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0 bytes of what she writes leave her laptop.&lt;/strong&gt; The page downloads code and model weights; her text never goes anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;€0 per use.&lt;/strong&gt; No API key, no subscription, no bill for my sister or for me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works offline&lt;/strong&gt; once the model is cached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could choose the model size&lt;/strong&gt; to fit a laptop GPU, and swap it later without asking anyone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A closed API would have meant sending her most anxious thoughts to a third party, paying per request, and needing a connection. For a gift, that's the wrong deal.&lt;/p&gt;

&lt;p&gt;The rest of the stack is open too: WebLLM and MLC, and the Gemma weights that make any of this possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest part
&lt;/h3&gt;

&lt;p&gt;This is a v0 built in one evening. It's a single HTML file of about 450 lines, styles included. It only works in Chrome or Edge on a computer, because it depends on WebGPU and fp16 shaders. It doesn't save anything between visits yet. The next version is already written for me, by her: tick, see progress, never lose a task. All of it can stay on her laptop.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Gemma&lt;/strong&gt;: the whole tool is Gemma 2 2B running locally in the browser through WebLLM on WebGPU, with JSON-schema-constrained output.&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>weekendchallenge</category>
      <category>devchallenge</category>
      <category>gemma</category>
    </item>
  </channel>
</rss>
