<?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: Shuchi Shukla</title>
    <description>The latest articles on DEV Community by Shuchi Shukla (@shuchi111).</description>
    <link>https://dev.to/shuchi111</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%2F964552%2F6886dcac-2e16-4e84-915f-9af97008dcb5.jpeg</url>
      <title>DEV Community: Shuchi Shukla</title>
      <link>https://dev.to/shuchi111</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shuchi111"/>
    <language>en</language>
    <item>
      <title>TrailSage</title>
      <dc:creator>Shuchi Shukla</dc:creator>
      <pubDate>Mon, 12 Oct 2026 05:35:57 +0000</pubDate>
      <link>https://dev.to/shuchi111/trailsage-30i4</link>
      <guid>https://dev.to/shuchi111/trailsage-30i4</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;h2&gt;
  
  
  I Built a Pocket Naturalist That Speaks Hindi &lt;em&gt;and&lt;/em&gt; English, and Refuses to Make Things Up
&lt;/h2&gt;

&lt;p&gt;I pointed my phone at a plant I'd walked past a hundred times. Five seconds later, a calm voice told me its name, a fact I didn't know, and whether it was rare, first in English, then in Hindi. Then it told me to put the phone away.&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;TrailSage&lt;/strong&gt;: point, shoot, learn. A bilingual pocket naturalist powered by an open-weight Gemma model, with an ElevenLabs-narrated recap of your whole walk at the end.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;TrailSage&lt;/strong&gt; is a mobile-first web app for anyone who walks past living things and wonders about them: hikers, gardeners, birders, kids on a school trip.&lt;/p&gt;

&lt;p&gt;The loop is deliberately short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Point your camera&lt;/strong&gt; at a plant, bird, insect, fungus, or animal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify.&lt;/strong&gt; An open-weight vision model (Gemma 4) answers in under 80 words: &lt;code&gt;NAME&lt;/code&gt;, one genuinely surprising &lt;code&gt;FACT&lt;/code&gt;, and &lt;code&gt;STATUS&lt;/code&gt; (common or rare)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hear it instantly.&lt;/strong&gt; The answer is spoken aloud by the phone's own TTS, in English and then in Hindi. No waiting on a cloud voice on a trail with one bar of signal&lt;/li&gt;
&lt;li&gt;Every sighting lands in a &lt;strong&gt;nature journal&lt;/strong&gt; (stored on-device)&lt;/li&gt;
&lt;li&gt;At the end of the walk, hit &lt;strong&gt;"Recap my walk"&lt;/strong&gt;. ElevenLabs' "George, the warm storyteller" voice narrates your journey like a nature-documentary outro&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It also speaks &lt;em&gt;in&lt;/em&gt;, not just out: a 🎤 button takes voice questions (Web Speech API), and everything, questions and answers, works in &lt;strong&gt;English, हिंदी, or both&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The design goal underneath everything: &lt;strong&gt;the app should want you to look up, not down.&lt;/strong&gt; Identify, listen, pocket the phone. The recap even ends with &lt;em&gt;"the screen stayed dark, the grass got touched."&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://trailsage.onrender.com/" rel="noopener noreferrer"&gt;https://trailsage.onrender.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video demo, outdoor walk, camera identify, bilingual voice, George's recap:&lt;/strong&gt; [&lt;a href="https://youtu.be/aY-6SQH-mbU?si=sTf8OWjOEDFGFLw9" rel="noopener noreferrer"&gt;https://youtu.be/aY-6SQH-mbU?si=sTf8OWjOEDFGFLw9&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The honesty screenshot I'm most proud of.&lt;/strong&gt; I fed it an abstract wallpaper and it answered, bilingually:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME: Not a living thing.
NAME: कोई जीवित वस्तु नहीं है।
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No invented species, no confident nonsense.&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%2F6tgj5vtugvyknxde79lv.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%2F6tgj5vtugvyknxde79lv.png" alt="TrailSage screen showing NAME: Not a living thing in English and Hindi, with the nature journal below" width="672" height="962"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/shuchi11/trailsage" rel="noopener noreferrer"&gt;https://github.com/shuchi111/trailsage&lt;/a&gt;. &lt;code&gt;server.js&lt;/code&gt; (Express, ~200 lines), &lt;code&gt;public/index.html&lt;/code&gt; (zero-build frontend), &lt;code&gt;instrument.js&lt;/code&gt; (Sentry preload), plus &lt;strong&gt;&lt;code&gt;TESTING.md&lt;/code&gt;&lt;/strong&gt;, a full test guide covering every endpoint, expected timings, and every failure mode I hit (more on that below).&lt;/p&gt;

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

&lt;p&gt;One Node/Express service, no build step, everything else is browser-native. The journal stays on the phone. Each action sends one small JSON body: the photo, a question, or the sighting titles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trailsage/
├── public/index.html   zero-build UI (camera, voice, journal, speech)
├── server.js           Express API: identify, ask, recap
├── instrument.js       Sentry preload (node --import, before the server)
└── render.yaml         single web service on Render
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Lives in&lt;/th&gt;
&lt;th&gt;Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trail UI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public/index.html&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Camera or file to JPEG, mic, on-device journal, spoken answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;server.js&lt;/code&gt; on Render&lt;/td&gt;
&lt;td&gt;Three JSON routes, plus &lt;code&gt;/api/health&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tracing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;instrument.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sentry on every &lt;code&gt;/api/*&lt;/code&gt; call (no-op if &lt;code&gt;SENTRY_DSN&lt;/code&gt; is unset)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vision + chat&lt;/td&gt;
&lt;td&gt;OpenAI-compatible LLM&lt;/td&gt;
&lt;td&gt;Gemma 4 first; &lt;code&gt;gemini-flash-lite&lt;/code&gt; if it stalls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Walk narration&lt;/td&gt;
&lt;td&gt;ElevenLabs&lt;/td&gt;
&lt;td&gt;George reads the English recap; browser TTS if that fails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Phone browser
├── camera or file upload → JPEG, long side 768px (~100 KB)
├── mic → Web Speech API (hi-IN or en-IN)
├── journal → localStorage (the photo stays on the phone)
└── speechSynthesis → English, then Hindi
        │
        │  one small JSON body
        ▼
Express on Render          Sentry traces every /api call
├── POST /api/identify → Gemma 4 (vision)
│                        └── no answer in 40s → gemini-flash-lite
├── POST /api/ask      → same models, text only, last journal titles
└── POST /api/recap    → ElevenLabs George (mp3)
                         └── no key, 402, or error → phone speaks instead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What crosses the network&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Identify&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /api/identify&lt;/code&gt;: JPEG (~100 KB) and &lt;code&gt;lang&lt;/code&gt; (&lt;code&gt;en&lt;/code&gt; / &lt;code&gt;hi&lt;/code&gt; / &lt;code&gt;both&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NAME&lt;/code&gt;, one &lt;code&gt;FACT&lt;/code&gt;, &lt;code&gt;STATUS&lt;/code&gt;, and which &lt;code&gt;model&lt;/code&gt; answered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ask&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /api/ask&lt;/code&gt;: question, journal context, &lt;code&gt;lang&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Short bilingual answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recap&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /api/recap&lt;/code&gt;: sighting titles only&lt;/td&gt;
&lt;td&gt;English and Hindi script, and either George's mp3 or a reason code (&lt;code&gt;no-key&lt;/code&gt;, &lt;code&gt;no-credits&lt;/code&gt;, &lt;code&gt;invalid-key&lt;/code&gt;, &lt;code&gt;tts-failed&lt;/code&gt;) so the phone speaks it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swap the model with two env vars&lt;/strong&gt; (&lt;code&gt;LLM_BASE_URL&lt;/code&gt;, &lt;code&gt;LLM_MODEL&lt;/code&gt;). Same code talks to Google AI Studio, the Hugging Face router, or a local Ollama.&lt;/p&gt;

&lt;p&gt;The design rule baked into every layer: &lt;strong&gt;degrade, don't die.&lt;/strong&gt; Model fallback, TTS fallback, camera fallback. The hiker never sees an error screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  The provider odyssey, or: my open-source model got delisted &lt;em&gt;twice&lt;/em&gt; mid-hackathon
&lt;/h3&gt;

&lt;p&gt;The plan said "Gemma 3 4B via Hugging Face router." Reality had opinions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;403:&lt;/strong&gt; &lt;code&gt;insufficient permissions to call Inference Providers&lt;/code&gt;. Fix: re-issue the fine-grained token with the "Make calls to Inference Providers" checkbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;402:&lt;/strong&gt; &lt;code&gt;no remaining credits&lt;/code&gt;. Free-tier inference credits were gone, and buying pre-paid credits wasn't an option for me.&lt;/li&gt;
&lt;li&gt;So I surveyed the alternatives: &lt;strong&gt;Groq, no Gemma. OpenRouter, Gemma delisted (only Gemini models remain). Cloudflare Workers AI, Gemma listed, but text-only, and I need vision.&lt;/strong&gt; By late 2026, hosted vision-capable Gemma had quietly disappeared from most serverless providers.&lt;/li&gt;
&lt;li&gt;Then I checked the one key I already had, a Google AI Studio key, against its models endpoint, and found this: &lt;strong&gt;&lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt;&lt;/strong&gt;. Google now hosts its newest &lt;em&gt;open-weight&lt;/em&gt; Gemma on the same OpenAI-compatible endpoint, free tier included.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The entire migration was &lt;strong&gt;one environment variable&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- LLM_BASE_URL=https://router.huggingface.co/v1
&lt;/span&gt;&lt;span class="gi"&gt;+ LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code, still an open-weight Gemma (now Gemma 4, a MoE with 4B active params, fast), zero cost per identification. The whole provider matrix the app ships with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;&lt;code&gt;LLM_BASE_URL&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Google AI Studio&lt;/strong&gt; (Gemma 4, current)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://generativelanguage.googleapis.com/v1beta/openai&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;free tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hugging Face router (Gemma 3)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://router.huggingface.co/v1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pre-paid credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Ollama (&lt;code&gt;gemma3:4b&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://localhost:11434/v1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;free, offline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Taming a thinking model (the part that ate my weekend)
&lt;/h3&gt;

&lt;p&gt;Gemma 4 is a &lt;em&gt;thinking&lt;/em&gt; model, and the OpenAI-compat layer ships its reasoning inside the response content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;thought&amp;gt;The user wants me to identify... * 8 paragraphs * ...&amp;lt;/thought&amp;gt;NAME: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three real problems, three fixes, all in the repo:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Thought leakage.&lt;/strong&gt; Answers arrived as walls of reasoning. The fix is a &lt;code&gt;stripThought()&lt;/code&gt; pass. Note the second half, which handles the nastier case where the reasoning &lt;em&gt;never closes its tag&lt;/em&gt; because it ran past the token limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stripThought&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;thought|think&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/\1&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// closed thoughts&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/?(&lt;/span&gt;&lt;span class="sr"&gt;thought|think&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// unclosed &amp;lt;thought&amp;gt; left behind when reasoning outran the token limit&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openIdx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;thought|think&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;openIdx&lt;/span&gt; &lt;span class="o"&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="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openIdx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a "think briefly (under 80 words of reasoning)" instruction in the prompt. Cheaper to prevent the spiral than to survive it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The invisible 105-second timeout.&lt;/strong&gt; I set a 35s per-attempt timeout and requests still took 105s. The OpenAI SDK retries failures &lt;strong&gt;twice by default&lt;/strong&gt;. My "35s timeout" was secretly 3×35s inside one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;LLM_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_MODEL&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;     &lt;span class="c1"&gt;// Gemma 4 → flash-lite&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxRetries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;              &lt;span class="c1"&gt;// the bug fix: own your retries&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stripThought&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* next model gets a turn */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. When the thinker stalls anyway.&lt;/strong&gt; That same loop &lt;em&gt;is&lt;/em&gt; the fix: Gemma 4 gets 40 seconds, then &lt;code&gt;gemini-flash-lite-latest&lt;/code&gt; (~2s, never thinks) answers. The response's &lt;code&gt;model&lt;/code&gt; field always says who answered, so the fallback is honest, not hidden.&lt;/p&gt;

&lt;p&gt;The rule I ended up with: &lt;strong&gt;the hiker never sees an error.&lt;/strong&gt; Every layer has a degradation path. Model fallback, ElevenLabs to browser TTS, camera to photo upload, studio voice to phone voice.&lt;/p&gt;

&lt;h3&gt;
  
  
  ElevenLabs: the 402 that wasn't about money
&lt;/h3&gt;

&lt;p&gt;Recaps worked instantly in testing but returned &lt;code&gt;402&lt;/code&gt; for everyone else. The full error body finally explained:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;paid_plan_required: Free users cannot use library voices via the API.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The hardcoded "Rachel" voice is a &lt;em&gt;paid library voice&lt;/em&gt;. My key was fine; my &lt;em&gt;voice choice&lt;/em&gt; wasn't. &lt;code&gt;GET /v1/voices&lt;/code&gt; listed the premade voices my tier can use, and TrailSage now narrates with &lt;strong&gt;George, "Warm, Captivating Storyteller"&lt;/strong&gt;, which is honestly a better fit for a nature-walk recap. The voice ID is an env var, and the UI reports &lt;em&gt;why&lt;/em&gt; it fell back (&lt;code&gt;no-key&lt;/code&gt; / &lt;code&gt;no-credits&lt;/code&gt; / &lt;code&gt;invalid-key&lt;/code&gt; / &lt;code&gt;tts-failed&lt;/code&gt;) instead of a generic apology.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bilingual everything
&lt;/h3&gt;

&lt;p&gt;One &lt;code&gt;lang&lt;/code&gt; field (&lt;code&gt;en&lt;/code&gt; / &lt;code&gt;hi&lt;/code&gt; / &lt;code&gt;both&lt;/code&gt;) flows from a header toggle into the prompt, the answer, and the speech. Bilingual answers are "English paragraph, blank line, Hindi paragraph", which makes the client-side speech trivially two-voiced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;both&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\n\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// English, blank line, हिंदी&lt;/span&gt;
  &lt;span class="nf"&gt;speakPart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;speakPart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hi-IN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server-side half is a single instruction appended to every prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;both&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reply in two parts separated by one blank line: first the full answer
       in English, then the same answer in Hindi (Devanagari script).&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 🎤 mic listens in &lt;code&gt;hi-IN&lt;/code&gt; or &lt;code&gt;en-IN&lt;/code&gt; to match the toggle, and the recap text itself is bilingual (though George narrates the English line for now. Devanagari TTS on free-tier premade voices is my next experiment).&lt;/p&gt;

&lt;h3&gt;
  
  
  Sentry in ESM-land
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@sentry/node&lt;/code&gt; v9 has no ESM default export. &lt;code&gt;(await import("@sentry/node")).default&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, which crashed the server the moment a DSN existed. The fix is the documented-but-easy-to-miss pattern: init in a separate &lt;code&gt;instrument.js&lt;/code&gt;, preload it with &lt;code&gt;node --import ./instrument.js server.js&lt;/code&gt; (baked into &lt;code&gt;npm start&lt;/code&gt;). Every identify/ask/recap shows up with latency and token counts. The graphs in this post come from that.&lt;/p&gt;

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

&lt;p&gt;Because &lt;strong&gt;it saved this project, live, on deadline weekend.&lt;/strong&gt; Not in principle. In fact.&lt;/p&gt;

&lt;p&gt;A closed-API version of TrailSage dies at step 2 of my odyssey: when the credits ran out, there'd be nothing to swap to, because the model would belong to someone else. The open-weight version just moved. Same code, same API shape, new home, from Hugging Face's router to Google AI Studio hosting the &lt;em&gt;same model family&lt;/em&gt;, in one line. When Groq and OpenRouter delisted Gemma, that portability was the difference between a submission and a post-mortem.&lt;/p&gt;

&lt;p&gt;What open weights specifically made possible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero cost per identification.&lt;/strong&gt; A kid can identify 200 plants on a class trip and it costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One env-var portability&lt;/strong&gt;, demonstrated twice in one weekend: HF router, Google AI Studio, and (locally) Ollama at &lt;code&gt;http://localhost:11434/v1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real offline path.&lt;/strong&gt; The same code can point at an Ollama running in Termux &lt;em&gt;on the phone&lt;/em&gt;. A fully offline pocket naturalist is one environment variable away (phone CPU makes it slow, but it works, and it's on my roadmap).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photos stay in open-weight pipelines.&lt;/strong&gt; No closed vendor's retention policy between a hiker's camera and their answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-tunable someday.&lt;/strong&gt; A Gemma tuned on regional flora isn't a negotiation with a vendor; it's a training run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Closed APIs sell you intelligence. Open weights hand you the keys &lt;em&gt;and&lt;/em&gt; the engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Want Next
&lt;/h2&gt;

&lt;p&gt;A whole walk in airplane mode. The camera, the journal, and the spoken English-then-Hindi answer already live on the phone. The dream is the model living there too, so a trail with no signal still gets a name, a fact, and a recap. Slower, yes. The screen still goes back in the pocket.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma (Featured):&lt;/strong&gt; the core, &lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt;, open-weight, vision, bilingual naturalist answers (with a documented one-line path back to &lt;code&gt;google/gemma-3-4b-it&lt;/code&gt; on the HF router)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render (Featured):&lt;/strong&gt; hosts the single service (&lt;code&gt;render.yaml&lt;/code&gt;, &lt;code&gt;npm start&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ElevenLabs (Partner):&lt;/strong&gt; George (premade voice) narrates the walk recap, with honest reason-coded fallbacks to browser TTS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry (Partner):&lt;/strong&gt; traces on every &lt;code&gt;/api/*&lt;/code&gt; call via the ESM &lt;code&gt;--import&lt;/code&gt; preload.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>week1challenge</category>
    </item>
    <item>
      <title>OmniCall: hear the parent message while class is still going</title>
      <dc:creator>Shuchi Shukla</dc:creator>
      <pubDate>Mon, 05 Oct 2026 06:14:31 +0000</pubDate>
      <link>https://dev.to/shuchi111/omnicall-hear-the-parent-message-while-class-is-still-going-19o7</link>
      <guid>https://dev.to/shuchi111/omnicall-hear-the-parent-message-while-class-is-still-going-19o7</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;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;OmniCall is for my friend Daniel, who teaches evening classes. Parents message during the lesson about fees, a broken class link, or a child leaving the class. Reading those messages means stopping the lesson.&lt;/p&gt;

&lt;p&gt;The teacher pastes what the parent wrote and presses &lt;strong&gt;Hear the reply&lt;/strong&gt;. Gemma decides the topic and how urgent it is. ElevenLabs speaks a short reply they can send back. MongoDB Atlas keeps the call for after class.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://youtu.be/Uakq0zQBerw" rel="noopener noreferrer"&gt;Watch the walkthrough&lt;/a&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%2F7myn7ftcbd3ag2g6fv3p.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%2F7myn7ftcbd3ag2g6fv3p.png" alt="OmniCall page after a class-link parent message, marked high priority, with the spoken reply" width="800" height="654"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One run used this parent message: “The class link is not working and my child cannot join.”&lt;/p&gt;

&lt;p&gt;The page said: “This is about the class link. high priority.”&lt;/p&gt;

&lt;p&gt;The voice said: “I’m sorry the link isn’t working. Please try refreshing your browser or check your email for a new link.”&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%2Ftbipw0q6q54h3o4u61ic.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%2Ftbipw0q6q54h3o4u61ic.png" alt="Sentry trace for the class-link call, showing Gemma, the database save, and ElevenLabs speech" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That trace shows model &lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt;, 1 model call, 232 tokens, 0 errors, and a cost under $0.01. The tools on the trace are &lt;code&gt;save_support_log&lt;/code&gt; and &lt;code&gt;elevenlabs_tts&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/shuchi111/omnicall-AI" rel="noopener noreferrer"&gt;https://github.com/shuchi111/omnicall-AI&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;The open model is Gemma, &lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt;, served through Google’s Gemma API. FastAPI sends the parent message to Gemma and asks for intent, priority, and two sentences written to be spoken.&lt;/p&gt;

&lt;p&gt;Atlas stores each call. The page lists the calls from that point on. ElevenLabs turns the sentences into audio, and the browser plays them. Sentry records the run as one agent trace: the Gemma chat, the database write, and the speech.&lt;/p&gt;

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

&lt;p&gt;Gemma makes the judgment. The teacher does not download a multi-gigabyte model, and the message is not sent to a closed chat product just to decide whether it is about fees or a class link. Changing the model is a name in the request, not a rewrite. The trace shows the exact model, the token count, and the cost of that decision.&lt;/p&gt;

&lt;p&gt;ElevenLabs is only the voice. Atlas is only the memory.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Best Use of Gemma&lt;/li&gt;
&lt;li&gt;Best Use of ElevenLabs&lt;/li&gt;
&lt;li&gt;Best Use of MongoDB Atlas&lt;/li&gt;
&lt;li&gt;Best Use of Sentry Agent Tracing&lt;/li&gt;
&lt;/ul&gt;

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