<?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: Evan Lin</title>
    <description>The latest articles on DEV Community by Evan Lin (@evanlin).</description>
    <link>https://dev.to/evanlin</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%2F409957%2Fc150d4a7-cb20-469d-a230-bac27232c577.jpeg</url>
      <title>DEV Community: Evan Lin</title>
      <link>https://dev.to/evanlin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/evanlin"/>
    <language>en</language>
    <item>
      <title>[AI in Action] Gemini 3.8 Live Upgrade: Paying Off Technical Debt and How My Test Script Fooled Me Three Times</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Tue, 29 Sep 2026 08:39:05 +0000</pubDate>
      <link>https://dev.to/evanlin/ai-in-action-gemini-38-live-upgrade-paying-off-technical-debt-and-how-my-test-script-fooled-me-5dh3</link>
      <guid>https://dev.to/evanlin/ai-in-action-gemini-38-live-upgrade-paying-off-technical-debt-and-how-my-test-script-fooled-me-5dh3</guid>
      <description>&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%2F51gkvqcrt42uoibfjapm.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%2F51gkvqcrt42uoibfjapm.png" alt="image-20260916214342753" width="800" height="558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Recap
&lt;/h1&gt;

&lt;p&gt;I have a LINE Bot that I use every day, &lt;a href="https://github.com/kkdai/linebot-helper-python" rel="noopener noreferrer"&gt;linebot-helper-python&lt;/a&gt;. It provides summaries and social media copy for URLs, allows continuous questioning for YouTube links, and handles bookmarks and location queries. One of its features is a LIFF voice assistant: it opens a webpage, connects to the Gemini Live API via WebSocket, and supports push-to-talk or hands-free conversation. The transcribed content is then summarized into a message and pushed back to the LINE chatroom.&lt;/p&gt;

&lt;p&gt;In mid-September, Google released &lt;a href="https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-8-live-gemini-3-8-live-extended-thinking/" rel="noopener noreferrer"&gt;Gemini 3.8 Live&lt;/a&gt;. After reading the announcement, what I wanted to do was slightly different from what they were trying to sell.&lt;/p&gt;

&lt;p&gt;The announcement focused on capabilities: Speech to Speech Index ranked first (82.6 points), Big Bench Audio at 97.7%, automatic detection of 97 languages with the ability to switch mid-conversation, tool calling executing in the background without interrupting the flow, and real-time visual input. There is also a &lt;code&gt;Gemini 3.8 Live Extended Thinking&lt;/code&gt; model that can reason while speaking.&lt;/p&gt;

&lt;p&gt;My first thought was: &lt;strong&gt;Now I can finally remove that preview exception.&lt;/strong&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  The Real Motivation: A Preview Model That Tripped Me Up Twice
&lt;/h1&gt;

&lt;p&gt;In my previous post about agentic video, I mentioned that my intelligent dialogue feature was broken for a while, and no one noticed. The reason was that &lt;code&gt;loader/chat_session.py&lt;/code&gt; had &lt;code&gt;gemini-3-pro-preview&lt;/code&gt; hardcoded. After that model was removed from Vertex AI, the actual path returned a 404, and the exception handling in that file simply raised the error.&lt;/p&gt;

&lt;p&gt;After fixing it, I added a set of guards using tests to enforce that "Model IDs can only appear in &lt;code&gt;config/agent_config.py&lt;/code&gt;" and "No preview or experimental models allowed." But those guards had two exceptions:&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="c1"&gt;# Live API only supports gemini-3.1-flash-live-preview; TTS is a separate model family.
# These two places must maintain preview models and are outside the scope of the guards.
&lt;/span&gt;&lt;span class="n"&gt;EXEMPT_FILES&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;services/voice_live.py&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;tools/tts_tool.py&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;The reason for the voice exception was "The Live API only has this one model available." At the time, that was true. But once that sentence is written into a comment, no one checks if it's still valid—the last loophole for the same type of bug sat there in plain sight for months.&lt;/p&gt;

&lt;p&gt;The significance of 3.8 Live, therefore, isn't just the capability upgrade. Its model name &lt;strong&gt;does not contain &lt;code&gt;-preview&lt;/code&gt;&lt;/strong&gt;, which means that exception can be removed.&lt;/p&gt;




&lt;h1&gt;
  
  
  Ask the API First, Not the Documentation
&lt;/h1&gt;

&lt;p&gt;This time, I didn't read the documentation first. Instead, I asked the API directly what actually exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://generativelanguage.googleapis.com/v1beta/models?key=&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;pageSize=200"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.models[]?.name'&lt;/span&gt; | rg &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'live'&lt;/span&gt;


models/gemini-3.5-transcribe-live
models/gemini-3.1-flash-live-preview
models/gemini-3.8-live
models/gemini-3.8-live-extended-thinking
models/gemini-3.5-live-translate-preview
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command gave me five confirmed model names, which is much more reliable than digging through documentation. &lt;code&gt;gemini-3.8-live&lt;/code&gt; is right there, with a clean name.&lt;/p&gt;

&lt;p&gt;I also happened to find &lt;code&gt;gemini-3.5-live-translate-preview&lt;/code&gt;, which I didn't know about. I was originally thinking about "whether to implement a real-time interpretation mode," and it seems that's a specialized model rather than a brute-forced general model. I'll look into that later.&lt;/p&gt;

&lt;p&gt;Next, I queried the metadata:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;3.1-flash-live-preview&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;3.8-live&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inputTokenLimit&lt;/td&gt;
&lt;td&gt;131072&lt;/td&gt;
&lt;td&gt;131072&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;outputTokenLimit&lt;/td&gt;
&lt;td&gt;65536&lt;/td&gt;
&lt;td&gt;65536&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;supportedGenerationMethods&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bidiGenerateContent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bidiGenerateContent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The numbers are identical. At this point, I had initial confidence that it would be a "painless swap," but this was just metadata, not behavior.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 1: Warning Against a Non-existent Risk
&lt;/h1&gt;

&lt;p&gt;When I first evaluated this, I solemnly noted: "The announcement didn't mention Vertex AI, and the decision for this project is to switch entirely to Vertex, so it's uncertain if 3.8 Live will be available."&lt;/p&gt;

&lt;p&gt;It sounded very professional. Then I looked at my own code:&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;live_genai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GOOGLE_AI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vertexai&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;http_options&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;api_version&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;v1beta&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vertexai=False&lt;/code&gt;. The voice path has used the Gemini API with an AI Studio key from the very beginning; it doesn't use Vertex at all—and 3.8 Live was released on the Gemini API.&lt;/p&gt;

&lt;p&gt;I was warning my own repo about a risk that didn't exist in my repo. The project as a whole indeed "switched entirely to Vertex AI," but voice was the exception, and I was the one who wrote that exception.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause and Solution&lt;/strong&gt;: Project-level decision records can become a memory shortcut. "We use Vertex for everything" is a correct summary, but summaries erase exceptions. Checking the source code takes thirty seconds and is much cheaper than reasoning based on impressions.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 2: The Control Group Saved Me
&lt;/h1&gt;

&lt;p&gt;After confirming the model existed, I wrote a throwaway script for testing. The key design was: &lt;strong&gt;Don't write a separate config; directly import the existing &lt;code&gt;build_live_config()&lt;/code&gt; and &lt;code&gt;build_voice_tools()&lt;/code&gt; from the project.&lt;/strong&gt; I wanted to test "Can the configuration I'm currently running work?", not "Can a separate configuration I wrote work?".&lt;/p&gt;

&lt;p&gt;The first run looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== gemini-3.8-live ===
  [handsfree] {'connected': True, 'audio': True, ..., 'error': None}
  [PTT] {'connected': True, 'audio': False, ...,
               'error': 'APIError: 1007 None. Precondition check failed.'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The push-to-talk (PTT) mode was blocked. If I had only tested 3.8, I would have concluded: "3.8 Live doesn't support PTT; cannot upgrade."&lt;/p&gt;

&lt;p&gt;But I also ran the old model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== gemini-3.1-flash-live-preview ===
  [PTT] {..., 'error': 'APIError: 1007 None. Precondition check failed.'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly the same error. Since both models failed in the same way, it wasn't a model issue; it was a script issue.&lt;/p&gt;

&lt;p&gt;The actual error was: In PTT mode, the production environment sends PCM audio, but I sent text for convenience. Inserting text input between &lt;code&gt;activity_start&lt;/code&gt; and &lt;code&gt;activity_end&lt;/code&gt; is an invalid combination.&lt;/p&gt;

&lt;p&gt;After switching to real 16kHz PCM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;===&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;gemini&lt;/span&gt;&lt;span class="mf"&gt;-3.8&lt;/span&gt;&lt;span class="err"&gt;-live&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;===&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;PTT&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;'connected':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'audio':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'out_tx':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'in_tx':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="err"&gt;'resume':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'events':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;'audio'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;'in_tx'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;'out_tx'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;'resume'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;'turn_complete'&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything was there, and it matched the old model item by item.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause and Solution&lt;/strong&gt;: The only thing I did right this time was &lt;strong&gt;keeping a control group&lt;/strong&gt;. When testing something new, testing the old thing in the same way costs almost nothing, but it allows you to distinguish between "the new thing is broken" and "my test is written incorrectly." These two things look identical.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 3: Sine Waves Aren't Human Voices
&lt;/h1&gt;

&lt;p&gt;In the second version of the script, I used &lt;code&gt;math.sin&lt;/code&gt; to generate a 180Hz PCM segment as audio input. PTT mode tested smoothly, but both models timed out in hands-free mode.&lt;/p&gt;

&lt;p&gt;I almost wrote this off as "hands-free behavior pending confirmation." But a moment's thought revealed why: hands-free mode relies on Gemini's own Voice Activity Detection (VAD), and a pure sine wave isn't speech. The VAD correctly judged that "no one is talking," so it never triggered.&lt;/p&gt;

&lt;p&gt;So it wasn't a "detected problem"; it was &lt;strong&gt;not detected at all&lt;/strong&gt;. Two timeouts looked like data, but they were actually two empty spaces.&lt;/p&gt;

&lt;p&gt;This later evolved into a checklist. I separated what the script tested from what it didn't:&lt;/p&gt;

&lt;p&gt;Tested: Connection, config acceptance, PTT audio round-trip, bidirectional transcripts, resumption handle, turn_complete, tool calling trigger with correct parameters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not tested&lt;/strong&gt;: Real human Chinese speech recognition, automatic VAD in hands-free mode, interrupting while speaking, reconnection after a ten-minute connection recycle, whether the resumption handle actually restores context, Google Search grounding actually taking effect, the second half of slow task delegation pushed back to LINE, voice quality, and latency.&lt;/p&gt;

&lt;p&gt;Of the eight items, eight were not tested. The script ran beautifully, but it touched the protocol layer, not what a user would encounter.&lt;/p&gt;




&lt;h1&gt;
  
  
  Comparison Table
&lt;/h1&gt;

&lt;p&gt;After going through the process, the final comparison looks like this. Both columns are results from hitting the real API using the &lt;strong&gt;project's existing config generator&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;Validation Item&lt;/th&gt;
&lt;th&gt;&lt;code&gt;3.1-flash-live-preview&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;3.8-live&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PTT (activity signal + PCM chunks)&lt;/td&gt;
&lt;td&gt;Audio / Bidirectional Transcripts / resume / turn_complete&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session_resumption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;context_window_compression&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Accepted&lt;/td&gt;
&lt;td&gt;Accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bidirectional &lt;code&gt;AudioTranscriptionConfig&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice &lt;code&gt;Aoede&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;google_search&lt;/code&gt; and &lt;code&gt;function_declarations&lt;/code&gt; in separate Tools&lt;/td&gt;
&lt;td&gt;Accepted&lt;/td&gt;
&lt;td&gt;Accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool calling actually triggered&lt;/td&gt;
&lt;td&gt;Both tool parameters correct&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api_version="v1beta"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Still required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In other words, &lt;code&gt;gemini-3.8-live&lt;/code&gt; can be swapped directly into the existing configuration.&lt;/p&gt;




&lt;h1&gt;
  
  
  Extended Thinking Has a Required Field
&lt;/h1&gt;

&lt;p&gt;I also tested &lt;code&gt;gemini-3.8-live-extended-thinking&lt;/code&gt;, and the connection was immediately blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APIError: 1007 None. Thinking level must be specified for this model.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After adding &lt;code&gt;thinking_config&lt;/code&gt;, both &lt;code&gt;LOW&lt;/code&gt; and &lt;code&gt;HIGH&lt;/code&gt; could connect. So it's not broken; it just has an extra required parameter.&lt;/p&gt;

&lt;p&gt;I decided not to adopt it because the current &lt;code&gt;build_live_config()&lt;/code&gt; doesn't send &lt;code&gt;thinking_config&lt;/code&gt;. If I just swapped the model name, the voice assistant would crash at the connection step. Using it would require modifying the config generator, which is a separate task.&lt;/p&gt;

&lt;p&gt;But I couldn't "keep" the knowledge that it shouldn't be set, so I wrote it as a test:&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="c1"&gt;# Live API (bidiGenerateContent) support list. Confirmed by connection test on 2026-09-16.
# Deliberately excludes gemini-3.8-live-extended-thinking: this model mandates thinking_level,
# and connections without thinking_config will be blocked (1007).
&lt;/span&gt;&lt;span class="n"&gt;LIVE_CAPABLE&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;gemini-3.8-live&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;Last time I wrote "Live API only supports a certain model" in a comment, that sentence expired for months without anyone noticing. This time, I wrote it as an assertion that will fail.&lt;/p&gt;




&lt;h1&gt;
  
  
  What Changed
&lt;/h1&gt;

&lt;p&gt;The actual code changes were small, about eighty lines across seven files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;config/agent_config.py&lt;/code&gt;&lt;/strong&gt;: Added &lt;code&gt;VOICE_MODEL&lt;/code&gt; module constant and &lt;code&gt;AgentConfig.voice_model&lt;/code&gt;. A detail here—I deliberately didn't just put it in &lt;code&gt;get_agent_config()&lt;/code&gt;, because that function requires &lt;code&gt;GOOGLE_CLOUD_PROJECT&lt;/code&gt;, and &lt;code&gt;services/voice_live.py&lt;/code&gt; needs the model ID at import time. If I went that route, imports would fail if the project environment variable wasn't set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;services/voice_live.py&lt;/code&gt;&lt;/strong&gt;: Removed the model literal entirely and pulled it from the config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tests/test_model_config.py&lt;/code&gt;&lt;/strong&gt;: Reduced &lt;code&gt;EXEMPT_FILES&lt;/code&gt; from two files to one; included &lt;code&gt;voice_model&lt;/code&gt; in the existing "no preview" and "must be in the verified capable list" guards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last guard originally looked like this:&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;test_voice_live_model_unchanged&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Live API only supports gemini-3.1-flash-live-preview; changing it will break the voice assistant.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;VOICE_MODEL&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-3.1-flash-live-preview&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asserted a string. Strings expire, and when they do, the test is still green—it only gets in your way when you want to upgrade; it doesn't save you when a model is taken down.&lt;/p&gt;

&lt;p&gt;By changing it to a list of capable models, it guards against the category of "this value must be a verified Live model," rather than a specific value.&lt;/p&gt;

&lt;p&gt;The exception for &lt;code&gt;tools/tts_tool.py&lt;/code&gt; remains. I checked, and all three TTS models on the API (&lt;code&gt;2.5-flash-preview-tts&lt;/code&gt;, &lt;code&gt;2.5-pro-preview-tts&lt;/code&gt;, &lt;code&gt;3.1-flash-tts-preview&lt;/code&gt;) are still in preview, with no GA versions to swap to. The exceptions were reduced from two to one, but not zeroed out.&lt;/p&gt;




&lt;h1&gt;
  
  
  Deployment Order: Config First, Code Second
&lt;/h1&gt;

&lt;p&gt;Environment variables were set before merging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run services update linebot-helper-python &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--update-env-vars&lt;/span&gt; &lt;span class="nv"&gt;VOICE_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gemini-3.8-live
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step had &lt;strong&gt;absolutely no effect&lt;/strong&gt; at the time. The live version was still running the old image, which had the model hardcoded in &lt;code&gt;voice_live.py&lt;/code&gt; and wouldn't even read this variable.&lt;/p&gt;

&lt;p&gt;But the order was correct. Merging triggers Cloud Build for automatic deployment. As soon as the new image goes up, it reads the already-in-place configuration, ensuring there isn't a window where "the code is up but the config isn't."&lt;/p&gt;

&lt;p&gt;This approach has a side effect that I find more valuable than the upgrade itself: &lt;strong&gt;Rollback doesn't require re-deployment.&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;gcloud run services update linebot-helper-python &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--update-env-vars&lt;/span&gt; &lt;span class="nv"&gt;VOICE_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gemini-3.1-flash-live-preview
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If real-world testing feels off, a single command switches it back without waiting for a build. Changing the model ID from hardcoded to an environment variable bought me this flexibility.&lt;/p&gt;




&lt;h1&gt;
  
  
  Then I Tested It for Real, and the Results Overturned My Observations
&lt;/h1&gt;

&lt;p&gt;There was one thing in the script I always found suspicious. During the tool calling round, the old model would speak a filler phrase before waiting for the tool result, while 3.8 did not.&lt;/p&gt;

&lt;p&gt;My judgment at the time was cautious: this &lt;strong&gt;might&lt;/strong&gt; be the "tools execute in the background without interrupting the flow" mentioned in the announcement, or it might just be my loop closing early. Automation couldn't tell; it required a human talking to know if it was an improvement or an awkward silence.&lt;/p&gt;

&lt;p&gt;After merging and deploying, I spoke a few sentences via LIFF. The answer: &lt;strong&gt;There is no difference from before; it's very smooth.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So that difference doesn't exist to a human ear. It was a byproduct of my loop returning as soon as it got &lt;code&gt;turn_complete&lt;/code&gt;. I was observing the shape of my own script.&lt;/p&gt;

&lt;p&gt;In this article, my script lied to me three times: the PTT incident made the new model look broken, the sine wave incident made "not tested" look like "tested," and this time made a non-existent difference look like a new capability. None of them were model issues.&lt;/p&gt;




&lt;h1&gt;
  
  
  296 Passing Tests Don't Prove Voice Works
&lt;/h1&gt;

&lt;p&gt;This is the part I find most worth writing down.&lt;/p&gt;

&lt;p&gt;After the upgrade, the tests went from 293 to 296, all green. But this number has almost nothing to do with "whether voice works on 3.8."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tests/test_voice_live.py&lt;/code&gt; has 32 tests covering PTT disabling auto-VAD, activity signals, transcript forwarding, tool calling execution, resumption handles, connection recycling, and interruptions. It looks comprehensive. But they all use &lt;code&gt;FakeLiveSession&lt;/code&gt; and don't connect to the internet. If the model changes from 3.1 to 3.8, not a single one of these 32 tests will change color.&lt;/p&gt;

&lt;p&gt;They test "Is what I'm sending correct?", not "What is the other side returning?".&lt;/p&gt;

&lt;p&gt;The three layers of coverage look like this:&lt;/p&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;What it tests&lt;/th&gt;
&lt;th&gt;Who runs it&lt;/th&gt;
&lt;th&gt;Repeatable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Repo test suite (296)&lt;/td&gt;
&lt;td&gt;Outgoing config and message format&lt;/td&gt;
&lt;td&gt;CI on every push&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throwaway probe (3)&lt;/td&gt;
&lt;td&gt;Protocol layer compatibility with real API&lt;/td&gt;
&lt;td&gt;Ran only once&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real human conversation&lt;/td&gt;
&lt;td&gt;Understanding, smoothness&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle layer is the only automated test that actually touched 3.8, and it's not in the repo, CI doesn't run it, and no one will know the next time a model is taken down.&lt;/p&gt;

&lt;p&gt;The newly added guard only blocks "setting it to a known bad value"; it doesn't block "Google taking 3.8-live down"—which is what actually happened last time.&lt;/p&gt;

&lt;p&gt;That loophole is still open. Turning the probe into a smoke test that requires a key is the solution; I haven't done it yet.&lt;/p&gt;




&lt;p&gt;The code is at &lt;a href="https://github.com/kkdai/linebot-helper-python" rel="noopener noreferrer"&gt;kkdai/linebot-helper-python&lt;/a&gt;, and this change is in PR #24. The official announcement is &lt;a href="https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-8-live-gemini-3-8-live-extended-thinking/" rel="noopener noreferrer"&gt;Gemini 3.8 Live&lt;/a&gt;, which mentions that all AI-generated audio carries a SynthID watermark—I did not verify this.&lt;/p&gt;

&lt;p&gt;The announcement spent the most space on real-time visual input, which I didn't touch at all this time—currently, the LIFF &lt;code&gt;getUserMedia&lt;/code&gt; is hardcoded to &lt;code&gt;video: false&lt;/code&gt;. That will probably be the next post.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>gemini</category>
      <category>python</category>
    </item>
    <item>
      <title>[AI in Practice] Shadowing Assessment with Gemini 3.5 Transcribe: Let Song Lingo Listen to You Read Lyrics</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Tue, 29 Sep 2026 08:38:38 +0000</pubDate>
      <link>https://dev.to/gde/ai-in-practice-shadowing-assessment-with-gemini-35-transcribe-let-song-lingo-listen-to-you-read-55ih</link>
      <guid>https://dev.to/gde/ai-in-practice-shadowing-assessment-with-gemini-35-transcribe-let-song-lingo-listen-to-you-read-55ih</guid>
      <description>&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%2Fbrespuv8mniqmoikbyxe.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%2Fbrespuv8mniqmoikbyxe.png" alt="image-20260928234100152" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Recap
&lt;/h1&gt;

&lt;p&gt;In the &lt;a href="https://dev.to/gde/ai-in-practice-gemini-38-flash-tts-launch-i-built-a-learn-japanese-with-mvs-web-app-and-4o79"&gt;first post&lt;/a&gt;, I used Gemini 3.8 Flash TTS to create Song Lingo: paste a YouTube MV URL, Gemini transcribes the lyrics, adds furigana, translation, and grammar, and then a teacher designed with voice design demonstrates pronunciation sentence by sentence. In the &lt;a href="https://dev.to/gde/ai-in-practice-deploying-song-lingo-to-cloud-run-making-a-private-lyrics-website-just-for-me-mb3"&gt;second post&lt;/a&gt;, I deployed it to Cloud Run and locked it with IAP so only I can use it.&lt;/p&gt;

&lt;p&gt;After deployment, I opened a roadmap on GitHub with three items: mobile layout, shadowing score, and flashcards. The mobile layout is finished; this post is about the second item.&lt;/p&gt;

&lt;p&gt;The teacher demonstrates, but I never know if I'm pronouncing it correctly. &lt;strong&gt;Listening to the teacher ten times is not as good as saying it once and being told where you're wrong.&lt;/strong&gt; This is a key step in Song Lingo's evolution from "listening to songs and reading lyrics" to "practicing pronunciation."&lt;/p&gt;




&lt;h1&gt;
  
  
  What is Gemini 3.5 Transcribe
&lt;/h1&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%2Fwww.evanlin.com%2Fimages%2FGoogle%2520Chrome%25202026-09-28%252023.40.44.tiff" 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%2Fwww.evanlin.com%2Fimages%2FGoogle%2520Chrome%25202026-09-28%252023.40.44.tiff" alt="Google Chrome 2026-09-28 23.40.44" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Google released &lt;a href="https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-5-transcribe/" rel="noopener noreferrer"&gt;Gemini 3.5 Transcribe&lt;/a&gt; on 8/26, which consists of two models specialized for speech-to-text:&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;Usage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-3.5-transcribe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Recorded audio files, transcribed in full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-3.5-transcribe-live&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Real-time streaming via Live API WebSocket, transcribing while speaking&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Key features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;85+ languages&lt;/strong&gt;, with auto-detection and support for code-switching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Word-level timestamps&lt;/strong&gt;: Each word has a start and end time (only available in the full transcription version).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speaker diarization&lt;/strong&gt;: Identifies who is speaking each sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom vocabulary&lt;/strong&gt;: Up to 1,000 proper nouns to improve recognition of specialized terms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two modes&lt;/strong&gt;: &lt;code&gt;smart&lt;/code&gt; handles self-corrections, removes filler words, and auto-formats; &lt;code&gt;verbatim&lt;/code&gt; transcribes word-for-word.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The official blog cites Artificial Analysis: average Word Error Rate (WER) of 2.6% for full transcription and 4.0% for streaming; compared to the previous generation Chirp 3, the time to get final results is 70% faster. Pricing has not been announced.&lt;/p&gt;

&lt;p&gt;The actual call method is the same as TTS via the &lt;code&gt;interactions&lt;/code&gt; API, but audio files must first be uploaded using the Files 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;uploaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;audio_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&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;mime_type&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;audio/m4a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;interaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interactions&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="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-3.5-transcribe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;input&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;type&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;audio&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;uri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;uploaded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mime_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;uploaded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mime_type&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="n"&gt;generation_config&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;transcription_config&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;language_codes&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;ja-JP&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;mode&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;type&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;verbatim&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;timestamp_granularities&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;word&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="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;Many formats are supported; &lt;code&gt;audio/webm&lt;/code&gt; happens to be the format for Chrome recordings, and &lt;code&gt;audio/m4a&lt;/code&gt; corresponds to AAC recorded by iPhone Safari.&lt;/p&gt;




&lt;h1&gt;
  
  
  Which Model to Choose
&lt;/h1&gt;

&lt;p&gt;For shadowing scores, there were three candidates:&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;Suitable for&lt;/th&gt;
&lt;th&gt;Use in Shadowing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;gemini-3.5-transcribe&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Transcribing after recording the whole segment&lt;/td&gt;
&lt;td&gt;✅ Has word-level timestamps, takes about 5s in testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-3.5-transcribe-live&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Real-time streaming&lt;/td&gt;
&lt;td&gt;A line of lyrics is only a few seconds; transcribing after finishing is enough. Maintaining WebSockets behind Cloud Run and IAP is much more troublesome.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gemini-3.8-flash&lt;/code&gt; and other multimodal models&lt;/td&gt;
&lt;td&gt;Listening to audio directly to provide text feedback&lt;/td&gt;
&lt;td&gt;Results vary every time, no guaranteed word-level timestamps, not suitable as the core for scoring.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I finally chose &lt;code&gt;gemini-3.5-transcribe&lt;/code&gt;. As a specialized speech-to-text model, the results are stable, it provides word-level info, and it only uses about 40 tokens per call, &lt;strong&gt;not consuming the 100-call daily quota for TTS&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  See Real Responses Before Coding
&lt;/h1&gt;

&lt;p&gt;The most expensive lesson from the first post was: the web version of TTS followed the Python SDK's way of reading &lt;code&gt;output_audio&lt;/code&gt;, but that field didn't exist in the REST response, burning a whole day's quota in an hour.&lt;/p&gt;

&lt;p&gt;So this time, I wrote a rule directly in the roadmap issue: &lt;strong&gt;Verify the parsing logic with real API responses before going live.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Test audio was generated using macOS's built-in &lt;code&gt;say&lt;/code&gt; command with sentences I wrote myself: one saying "今日は晴れです" (Today is sunny) and another intentionally saying "今日は雨です" (Today is rainy):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;say &lt;span class="nt"&gt;-v&lt;/span&gt; Kyoko &lt;span class="nt"&gt;-o&lt;/span&gt; ok.aiff &lt;span class="s2"&gt;"今日は晴れです"&lt;/span&gt;
afconvert &lt;span class="nt"&gt;-f&lt;/span&gt; m4af &lt;span class="nt"&gt;-d&lt;/span&gt; aac ok.aiff ok.m4a &lt;span class="c"&gt;# Same AAC format as iPhone recordings&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I intercepted the raw HTTP content sent and received by the SDK and printed the response structure. I discovered two things not clearly explained in the documentation that would have caused errors if followed blindly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;output_text&lt;/code&gt; is assembled by the SDK again
&lt;/h2&gt;

&lt;p&gt;Documentation says transcription results are in &lt;code&gt;interaction.output_text&lt;/code&gt;. The actual REST response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model_output"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="err"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;text:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"今日は晴れです。"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                          &lt;/span&gt;&lt;span class="err"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"word_info"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;text:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"今日"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                                            &lt;/span&gt;&lt;span class="err"&gt;start_index:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;end_index:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                                            &lt;/span&gt;&lt;span class="err"&gt;start_offset:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.100s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;end_offset:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.400s"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;output_text&lt;/code&gt;, like &lt;code&gt;output_audio&lt;/code&gt; last time, is a convenience field the Python SDK assembles from &lt;code&gt;steps&lt;/code&gt;. Knowing this beforehand, I read directly from &lt;code&gt;steps[].content[]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Word positions are calculated in UTF-8 bytes
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;start_index&lt;/code&gt; for the word "今日" is 0 and &lt;code&gt;end_index&lt;/code&gt; is 6, not 0 to 2. &lt;strong&gt;Positions are calculated using UTF-8 bytes&lt;/strong&gt;, where one CJK character takes 3 bytes. If sliced according to JavaScript string indices, Japanese text would be completely misaligned.&lt;/p&gt;

&lt;p&gt;These two things can only be known by looking at real responses. This time, spending two API calls meant no more guessing after going live.&lt;/p&gt;




&lt;h1&gt;
  
  
  How to Determine "Correct Pronunciation"
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Use verbatim, not smart
&lt;/h2&gt;

&lt;p&gt;The official &lt;code&gt;smart&lt;/code&gt; mode handles self-corrections and removes filler words. While a plus for meeting minutes, it's a minus for shadowing: &lt;strong&gt;if you mispronounce and then correct yourself, smart might only keep the corrected version; if you miss a particle, it might fill it in smoothly.&lt;/strong&gt; Shadowing needs "what you actually said," so use &lt;code&gt;verbatim&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't use custom vocabulary
&lt;/h2&gt;

&lt;p&gt;Setting the original text as custom vocabulary seems like it would improve accuracy, but it makes the model &lt;strong&gt;biased toward hearing the correct answer&lt;/strong&gt;, making it harder to catch mistakes. Also, documentation states custom vocabulary and word-level timestamps cannot be used together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare both pronunciation and writing
&lt;/h2&gt;

&lt;p&gt;Transcription results might be in Kanji "今日" or Hiragana "きょう"; both are correct. Comparing only text would mark correct pronunciations as wrong.&lt;/p&gt;

&lt;p&gt;So for Japanese, I compare both ways simultaneously; &lt;strong&gt;it counts as correct if either matches&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pronunciation&lt;/strong&gt;: Convert transcription to Hiragana using pykakasi and compare with the pre-labeled pronunciation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing&lt;/strong&gt;: Compare text directly to prevent pykakasi from misreading a Kanji.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The comparison itself uses Python's built-in &lt;code&gt;difflib.SequenceMatcher&lt;/code&gt; for character alignment, mapping results back to each word. English and Korean are compared word-by-word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distinguish between "Mispronounced" and "Missed"
&lt;/h2&gt;

&lt;p&gt;The first version only checked "if the word's characters matched." Consequently, "晴れ" in "今日は雨です" was marked as &lt;strong&gt;missed&lt;/strong&gt;, when it was actually &lt;strong&gt;replaced by another word&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;After changing to record alignment results for each character (match, replaced, deleted), they can be distinguished:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Transcription Result&lt;/th&gt;
&lt;th&gt;Judgment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;今日は晴れです。&lt;/td&gt;
&lt;td&gt;All correct, 100 points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replaced a word&lt;/td&gt;
&lt;td&gt;今日は雨です。&lt;/td&gt;
&lt;td&gt;"晴れ" mispronounced, 75 points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skipped middle word&lt;/td&gt;
&lt;td&gt;今日はです&lt;/td&gt;
&lt;td&gt;"晴れ" missed, 75 points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stopped halfway&lt;/td&gt;
&lt;td&gt;今日は&lt;/td&gt;
&lt;td&gt;"晴れ" "です" missed, 50 points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transcribed as Hiragana&lt;/td&gt;
&lt;td&gt;きょうははれです&lt;/td&gt;
&lt;td&gt;All correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Katakana with full-width symbols&lt;/td&gt;
&lt;td&gt;キョウハ　ハレデス！&lt;/td&gt;
&lt;td&gt;All correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Added filler words before/after&lt;/td&gt;
&lt;td&gt;えっと今日は晴れですね&lt;/td&gt;
&lt;td&gt;All correct&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are unit tests that don't call the API, allowing the comparison logic to be tested instantly after any modification.&lt;/p&gt;




&lt;h1&gt;
  
  
  Clarifying Limitations First
&lt;/h1&gt;

&lt;p&gt;Speech-to-text checks " &lt;strong&gt;whether others can understand which word you are saying&lt;/strong&gt;," not fine-grained pronunciation scoring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Slightly non-standard but still recognizable words &lt;strong&gt;will be judged as correct&lt;/strong&gt;. The model guesses the most likely word, which is the nature of speech-to-text.&lt;/li&gt;
&lt;li&gt;Details like long vowels, geminate consonants (っ), and pitch accent are only caught if they are wrong enough to become a different word.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Therefore, the card description explicitly states: "Only checks if the word is recognizable; pitch and vowel length are not within the scoring scope." If more detailed feedback is needed later, the recording and the teacher's demo can be sent to &lt;code&gt;gemini-3.8-flash&lt;/code&gt; for a textual explanation, but that's a separate feature.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 1: Button says "Recording" before the microphone is ready
&lt;/h1&gt;

&lt;p&gt;The first version's flow was: Click "Shadow" -&amp;gt; Button immediately changes to "■ Stop and Score" -&amp;gt; Request microphone.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;strong&gt;on the first use, the browser pops up a microphone permission prompt&lt;/strong&gt;. At this point, the button already says "Stop and Score," but the mic hasn't started recording. If clicked again, the program thinks it hasn't started and &lt;strong&gt;requests the microphone again&lt;/strong&gt; instead of stopping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Added a "Preparing Microphone" state. After clicking, the button shows "Preparing Microphone..." and is temporarily disabled. &lt;strong&gt;Only when &lt;code&gt;MediaRecorder&lt;/code&gt; actually starts recording does it switch to "Stop and Score."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By the way, the issue originally specified "Hold to record, release to send," but I changed it to "Click to start, click to stop" during implementation because of this permission prompt: if holding, the finger would definitely release when clicking the permission button, making the first attempt fail. Plus, long-pressing on iPhone easily triggers text selection, and saying a whole lyric takes several seconds; clicking twice is more user-friendly than holding.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 2: Headless Chrome on Mac can't access the microphone
&lt;/h1&gt;

&lt;p&gt;For end-to-end testing, I've been using Puppeteer with headless Chrome. Chrome has a set of test flags to "use an audio file to fake a microphone":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--use-fake-ui-for-media-stream
--use-fake-device-for-media-stream
--use-file-for-fake-audio-capture=ok.wav

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, &lt;code&gt;getUserMedia&lt;/code&gt; just hung without responding. I tried 48kHz wav, not specifying a file, pre-granting mic permissions—nothing worked. It couldn't even get a basic fake device; it seems macOS's microphone permission mechanism blocks headless Chrome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Instead of getting stuck on the test environment, I changed the approach. Before the page loads, I replace &lt;code&gt;getUserMedia&lt;/code&gt; to decode a test audio file and play it via Web Audio, creating a real audio stream:&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getUserMedia&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48000&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;buffer&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decodeAudioData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testWavBytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBufferSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buffer&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;dest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaStreamDestination&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stream&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;Except for the "system microphone" part, the entire flow—recording (&lt;code&gt;MediaRecorder&lt;/code&gt; to webm/opus), uploading, transcribing, comparing, and displaying results—is real. The test result was 100 points, all four words green, only one scoring request sent, and no residual files in the server's temp folder.&lt;/p&gt;

&lt;p&gt;The bug from Pitfall 1 was also caught in the first version of this test: the button showed "Stop and Score," but &lt;code&gt;MediaRecorder&lt;/code&gt; was never created.&lt;/p&gt;




&lt;h1&gt;
  
  
  Privacy and Quota
&lt;/h1&gt;

&lt;p&gt;Shadowing involves two sensitive things: &lt;strong&gt;my recordings&lt;/strong&gt; and &lt;strong&gt;API quota&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;Design&lt;/th&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Recordings not saved&lt;/td&gt;
&lt;td&gt;Only written to the server's temp folder during scoring, then deleted immediately along with the folder (in a &lt;code&gt;finally&lt;/code&gt; block); not stored in buckets or logged.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't trust lyrics from the browser&lt;/td&gt;
&lt;td&gt;The server reads the original text and pronunciation itself based on song ID and line number.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No double billing&lt;/td&gt;
&lt;td&gt;Only one scoring process at a time; SDK auto-retry is disabled.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop when quota is exhausted&lt;/td&gt;
&lt;td&gt;After receiving a 429, requests are rejected during the &lt;code&gt;Retry-After&lt;/code&gt; period without calling Gemini.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inaccessible to unauthenticated users&lt;/td&gt;
&lt;td&gt;The shadowing API is also behind IAP; unauthenticated requests are blocked at the IAP layer before reaching the app, so no quota is consumed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sending recordings to Gemini for transcription is necessary for the feature itself, but on the service side, the recording only exists for the few seconds of that call.&lt;/p&gt;




&lt;h1&gt;
  
  
  Side note: Making PWA installable behind IAP
&lt;/h1&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%2Fkz0ycdei8zct3u8oz70z.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%2Fkz0ycdei8zct3u8oz70z.png" alt="image-20260928234132888" width="800" height="1734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first item on the roadmap was the mobile layout: MV fixed at the top, cards swipeable left/right, thumb-accessible bottom control bar, plus a PWA that can be added to the home screen. The layout itself isn't special, but there's a pitfall with PWA behind IAP worth noting.&lt;/p&gt;

&lt;p&gt;When browsers fetch the PWA manifest, &lt;strong&gt;they don't include cookies by default&lt;/strong&gt;. Behind IAP, requests without cookies are redirected to the Google login page, so the manifest can't be read, and the PWA can't be installed.&lt;/p&gt;

&lt;p&gt;The HTML solution is to add &lt;code&gt;crossorigin="use-credentials"&lt;/code&gt; to the &lt;code&gt;&amp;lt;link rel="manifest"&amp;gt;&lt;/code&gt;. However, looking at the Next.js 16 source code, I found its built-in manifest link &lt;strong&gt;only adds this attribute in Vercel preview environments&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;crossOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;manifestOrigin&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERCEL_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use-credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Instead of using Next's built-in &lt;code&gt;app/manifest.ts&lt;/code&gt;, I used a regular route to provide the manifest and manually placed the link with &lt;code&gt;use-credentials&lt;/code&gt; in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. Icons in the manifest were also embedded as data URLs to prevent the browser from making another cookie-less request.&lt;/p&gt;




&lt;h1&gt;
  
  
  Results and Benefits
&lt;/h1&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;Figures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Commits this time&lt;/td&gt;
&lt;td&gt;2 (Mobile layout &amp;amp; PWA, Shadowing score)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total repo commits&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closed issues&lt;/td&gt;
&lt;td&gt;2 / 3 (roadmap: flashcards remaining)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One shadowing score&lt;/td&gt;
&lt;td&gt;Approx. 9–10s (upload ~2s, transcription ~4s, rest is Python startup)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage per score&lt;/td&gt;
&lt;td&gt;Approx. 40 tokens, doesn't consume TTS quota&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calls spent verifying API&lt;/td&gt;
&lt;td&gt;5–6 transcribe calls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The workflow for learning a song on mobile is now: View card → Listen to teacher → Press shadow and speak → See which words were wrong → Listen to your recording vs. teacher → Swipe to next line.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;"See real responses first" must be a rule, not a memory.&lt;/strong&gt; I explicitly wrote this in the issue and made two API calls before starting. As a result, I knew about &lt;code&gt;output_text&lt;/code&gt; and UTF-8 bytes before writing a single line of code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The officially promoted mode isn't always best for your use case.&lt;/strong&gt; &lt;code&gt;smart&lt;/code&gt; mode is a plus for most scenarios, but shadowing needs exactly what it removes. Think about whether you want "organized results" or "what actually happened" before choosing a mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Features that improve accuracy might actually be counterproductive.&lt;/strong&gt; Custom vocabulary biases the model toward the correct answer, while scoring needs to catch errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UI state should reflect real state, not assumed state.&lt;/strong&gt; When the button says "Recording," the mic isn't actually on yet. This gap is most obvious during the first authorization, which is exactly when users are most likely to click randomly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the test environment hangs, change the approach.&lt;/strong&gt; Instead of fighting macOS mic permissions, replace the "system microphone" segment and let the rest run for real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clarify capabilities upfront.&lt;/strong&gt; Speech-to-text checks "understandability," not pronunciation. Writing this limitation on the screen is more honest than letting users think a 100-point score means perfect pronunciation.&lt;/p&gt;

&lt;p&gt;Code is at &lt;a href="https://github.com/kkdai/song-lingo" rel="noopener noreferrer"&gt;kkdai/song-lingo&lt;/a&gt;, shadowing implementation is in &lt;code&gt;shadow.py&lt;/code&gt; and &lt;code&gt;web/app/api/songs/[id]/lines/[index]/shadow/&lt;/code&gt;. Official resources: &lt;a href="https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-5-transcribe/" rel="noopener noreferrer"&gt;Gemini 3.5 Transcribe Launch Post&lt;/a&gt;, &lt;a href="https://ai.google.dev/gemini-api/docs/transcribe" rel="noopener noreferrer"&gt;Transcribe Documentation&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cloud</category>
      <category>gemini</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Hands-on with the official google-cloud-developer plugin: Stop AI agents from hallucinating gcloud commands</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Mon, 28 Sep 2026 15:54:43 +0000</pubDate>
      <link>https://dev.to/gde/hands-on-with-the-official-google-cloud-developer-plugin-stop-ai-agents-from-hallucinating-gcloud-15h6</link>
      <guid>https://dev.to/gde/hands-on-with-the-official-google-cloud-developer-plugin-stop-ai-agents-from-hallucinating-gcloud-15h6</guid>
      <description>&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%2Fc1dkqd1ueyh6hobg91jx.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%2Fc1dkqd1ueyh6hobg91jx.png" alt=" " width="799" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Recently, while browsing Google Cloud's &lt;a href="https://docs.cloud.google.com/docs/get-started/developer-environment?hl=en#antigravity-cli" rel="noopener noreferrer"&gt;developer environment setup documentation&lt;/a&gt;, I noticed something new amidst the usual gcloud CLI, Cloud Shell, and Cloud Workstations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agy plugin &lt;span class="nb"&gt;install &lt;/span&gt;https://github.com/google/skills/plugins/cloud/google-cloud-developer

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;agy&lt;/code&gt; is the Google Antigravity CLI. But what really made me stop wasn't this new CLI, but the URL following it: Google has started packaging its own product knowledge as agent plugins and publishing them on GitHub.&lt;/p&gt;

&lt;p&gt;As it turns out, when I checked my own Claude Code, this plugin was already installed on my machine. So, in this post, we won't bother installing the Antigravity CLI first; instead, we'll use what's already available to see what it actually does.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's inside this plugin?
&lt;/h3&gt;

&lt;p&gt;Let's take it apart. The entire package is actually quite small: 5 skills, 1 set of routing rules, and 1 MCP server, totaling 1549 lines of markdown with no code at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;skills/gcloud/ # Safety guardrails and syntax validation for gcloud commands (272 lines + 340 lines of reference docs)
skills/retrieving-developer-knowledge/ # Querying official documentation (103 lines + 182 lines of reference docs)
skills/finding-google-skills/ # Fetching skills from a remote directory on demand (134 lines)
skills/google-cloud-recipe-onboarding/ # Onboarding for a user's first project (228 lines)
skills/google-cloud-recipe-auth/ # Credentials and ADC selection (260 lines)
rules/google-cloud-discovery.md # Skill routing table (30 lines)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interestingly, four sets of manifests are packed into the same directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.claude-plugin/plugin.json # Claude Code
.codex-plugin/plugin.json # Codex
gemini-extension.json # Gemini CLI / Antigravity
plugin.json # Universal format

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The universal &lt;code&gt;plugin.json&lt;/code&gt; starts like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"google-cloud-developer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.1.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Google LLC"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"license"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Apache-2.0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;agent-plugins.org&lt;/code&gt; is a cross-tool plugin schema. Google didn't create a proprietary format for its own Antigravity; instead, they made one package that four different types of agents can consume. In other words, you can use this content even without installing the Antigravity CLI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installation: Three Paths to Choose From
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Antigravity CLI (As per documentation)&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;agy plugin &lt;span class="nb"&gt;install &lt;/span&gt;https://github.com/google/skills/plugins/cloud/google-cloud-developer

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Claude Code&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;claude plugin marketplace add google/skills
claude plugin &lt;span class="nb"&gt;install &lt;/span&gt;google-cloud-developer@google-plugins

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Universal&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;npx skills add google/skills

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After installation, you need to enable the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud services &lt;span class="nb"&gt;enable &lt;/span&gt;developerknowledge.googleapis.com &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To confirm where it's installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; ~/.claude/plugins/cache/google-plugins/google-cloud-developer/
1.1.2

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Test 1: Can guardrails stop hallucinated flags?
&lt;/h3&gt;

&lt;p&gt;The core of this plugin is the &lt;code&gt;gcloud&lt;/code&gt; skill. Its opening paragraph is very blunt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All pre-existing knowledge of &lt;code&gt;gcloud&lt;/code&gt; commands, flags, flag values, and positional argument syntax is &lt;strong&gt;stale and prone to hallucination&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In plain English: The gcloud syntax you (the model) remember is outdated; do not write it from memory. Consequently, it sets a hard rule: before taking action, you must run &lt;code&gt;gcloud help &amp;lt;leaf command&amp;gt;&lt;/code&gt;. Furthermore, parent-level help doesn't count; you must validate down to the lowest-level sub-command. It even explicitly forbids using web searches to check syntax; &lt;code&gt;gcloud help&lt;/code&gt; is the sole authority.&lt;/p&gt;

&lt;p&gt;Is this rule necessary? Let's test it with Cloud Run's scaling flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;--min-scale&lt;/span&gt; &lt;span class="nt"&gt;--max-scale&lt;/span&gt; &lt;span class="nt"&gt;--min-instances&lt;/span&gt; &lt;span class="nt"&gt;--max-instances&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;gcloud &lt;span class="nb"&gt;help &lt;/span&gt;run deploy | rg &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt; exists"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt; does not exist"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="nt"&gt;--min-scale&lt;/span&gt; does not exist
&lt;span class="nt"&gt;--max-scale&lt;/span&gt; does not exist
&lt;span class="nt"&gt;--min-instances&lt;/span&gt; exists
&lt;span class="nt"&gt;--max-instances&lt;/span&gt; exists

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--min-scale&lt;/code&gt; / &lt;code&gt;--max-scale&lt;/code&gt; were the syntax for Knative and early Cloud Run, but they are no longer used. However, these strings are everywhere in old articles and Stack Overflow answers online, so models are very likely to spit out a command that looks reasonable but fails immediately upon execution. By running &lt;code&gt;gcloud help&lt;/code&gt; first, this problem disappears.&lt;/p&gt;

&lt;p&gt;By the way, &lt;code&gt;gcloud run deploy&lt;/code&gt; doesn't actually have a &lt;code&gt;--dry-run&lt;/code&gt; flag; it only has &lt;code&gt;--async&lt;/code&gt;. The skill's rule is: "If the help output lists &lt;code&gt;--dry-run&lt;/code&gt; or &lt;code&gt;--validate-only&lt;/code&gt;, you must run it once first." If it's listed, run it; if not, skip it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 2: How much context does a blind list consume?
&lt;/h3&gt;

&lt;p&gt;There is a rule in the skill that I initially thought was a bit tedious:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;DO NOT execute any &lt;code&gt;list&lt;/code&gt; command without including at least one data reduction flag (&lt;code&gt;--limit&lt;/code&gt;, &lt;code&gt;--filter&lt;/code&gt;, or &lt;code&gt;--format&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A quick measurement shows why. I have a project running 52 Cloud Run services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# A. Blind list&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;gcloud run services list &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
281918

&lt;span class="c"&gt;# B. Projecting only the two required fields (still 52 services, none missing)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;gcloud run services list &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"json(metadata.name,status.url)"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
7963

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;281,918 bytes vs. 7,963 bytes—a 35x difference.&lt;/strong&gt; In terms of tokens, that's roughly 70k vs. 2k. In other words, a &lt;code&gt;list&lt;/code&gt; command without &lt;code&gt;--format&lt;/code&gt; would consume 70k tokens, and 97% of what was cut out consists of annotations, conditions, revision templates, and a bunch of timestamps.&lt;/p&gt;

&lt;p&gt;The skill also teaches a very practical "probe schema before querying full data" trick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud &amp;lt;GROUP&amp;gt; &amp;lt;RESOURCE&amp;gt; list &lt;span class="nt"&gt;--limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, grab one entry to see what the JSON looks like. After confirming the field paths, then construct the &lt;code&gt;--filter&lt;/code&gt; and &lt;code&gt;--format&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Other execution rules follow the same logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run only one command at a time; no &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; chaining.&lt;/li&gt;
&lt;li&gt;Prohibit pipes, &lt;code&gt;$( )&lt;/code&gt;, and redirection (the reason being to keep commands readable for human review).&lt;/li&gt;
&lt;li&gt;Always add &lt;code&gt;--project=&amp;lt;PROJECT_ID&amp;gt;&lt;/code&gt;; do not rely on the default values of the active config.&lt;/li&gt;
&lt;li&gt;Always add &lt;code&gt;--quiet&lt;/code&gt; to avoid getting stuck on interactive confirmations in environments without a TTY.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Test 3: Which commands is it forbidden to run?
&lt;/h3&gt;

&lt;p&gt;This is the section I find most worth copying. The skill contains an explicit denylist of operations that &lt;strong&gt;must never be executed automatically without explicit human authorization&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Any changes to IAM policy / role / binding (risk of privilege escalation or locking oneself out).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gcloud * delete&lt;/code&gt; (irreversible resource destruction).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gcloud billing *&lt;/code&gt; (risk of billing spikes).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gcloud organizations *&lt;/code&gt; (organization-level settings affect everyone).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gcloud kms *&lt;/code&gt; (potential to permanently lock data).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gcloud infra-manager deployments apply&lt;/code&gt; (automatically running IaC might delete a whole batch of resources).&lt;/li&gt;
&lt;li&gt;Proactively enabling APIs is also on the list, the reason being that enabling an API might trigger billing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I find that last one particularly interesting. Enabling an API sounds harmless, but the skill explicitly states "assume necessary APIs are enabled"; if they need to be enabled, go back and ask the human. This level of conservatism shows they have seriously thought about what happens when an agent is connected to a production environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 4: Developer Knowledge—Querying docs instead of memory
&lt;/h3&gt;

&lt;p&gt;The plugin includes an MCP server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"developer-knowledge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"streamable-http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://developerknowledge.googleapis.com/mcp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here I encountered the first pitfall. Just because an MCP server is declared in the configuration file doesn't mean it actually connects. The tools listed on my end were only &lt;code&gt;authenticate&lt;/code&gt; and &lt;code&gt;complete_authentication&lt;/code&gt;; the &lt;code&gt;answer_query&lt;/code&gt; and &lt;code&gt;search_documents&lt;/code&gt; mentioned in the documentation didn't appear at all, meaning it was stuck in the authentication process.&lt;/p&gt;

&lt;p&gt;Interestingly, the skill itself had already anticipated this, stating in black and white:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A declared server is not always a connected server.&lt;/strong&gt; Some clients cannot complete the MCP handshake with this server and expose no &lt;code&gt;answer_query&lt;/code&gt;, &lt;code&gt;search_documents&lt;/code&gt; or &lt;code&gt;get_documents&lt;/code&gt; tool at all, even though the plugin declares one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It then provides a REST fallback. You can use the gcloud credentials you already have to make the call, without needing to apply for a separate API key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://developerknowledge.googleapis.com/v1:answerQuery"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Goog-User-Project: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud config get-value project&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"query": "How do I deploy a container to Cloud Run with gcloud?"}'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HTTP 200&lt;/code&gt;, and the returned data is more sophisticated than I expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;jq &lt;span class="s1"&gt;'.answer | keys'&lt;/span&gt; response.json
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"answerText"&lt;/span&gt;, &lt;span class="s2"&gt;"citations"&lt;/span&gt;, &lt;span class="s2"&gt;"references"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;jq &lt;span class="s1"&gt;'.answer.references | length'&lt;/span&gt; response.json
10

&lt;span class="nv"&gt;$ &lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.answer.references[].documentReference.documentChunk.document.uri'&lt;/span&gt; response.json | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
https://docs.cloud.google.com/run/docs/deploying
https://docs.cloud.google.com/run/docs/quickstarts/deploy-container
https://docs.cloud.google.com/sdk/gcloud/reference/run/deploy
https://docs.cloud.google.com/run/docs/configuring/services/containers
https://docs.cloud.google.com/run/docs/create-jobs
...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't just provide an answer; it attaches 10 official documents as sources, and &lt;code&gt;citations&lt;/code&gt; use character ranges to map to the reference index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"startIndex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;447&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"endIndex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;505&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"referenceIndex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"referenceIndex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Characters 447 to 505 in the answer come from the 3rd and 7th documents. With this level of granularity in citations, verifying the truth of the answer becomes much easier.&lt;/p&gt;

&lt;p&gt;To query precise syntax, use another endpoint. I tested it with Cloud Run IAM permission strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"https://developerknowledge.googleapis.com/v1/documents:searchDocumentChunks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"query=cloud run invoker IAM permission run.routes.invoke"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"pageSize=3"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Goog-User-Project: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud config get-value project&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retrieved permission strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run.routes.invoke
run.services.create
run.services.delete
run.services.get
run.services.list
run.services.update

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These &lt;code&gt;service.resource.verb&lt;/code&gt; format strings are where models are most likely to miss a character or add an extra 's'. Checking them is much more reliable than guessing.&lt;/p&gt;

&lt;p&gt;A small pitfall to note: the skill's own fallback documentation says the response should be read from &lt;code&gt;documentChunks[].content&lt;/code&gt;, but the actual field returned is &lt;code&gt;results[].content&lt;/code&gt;. This minor discrepancy is telling in itself. Even official documentation can be out of sync with the actual API, which is why the "verify before acting" rule is indeed useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 5: How 137 skills avoid consuming context
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;finding-google-skills&lt;/code&gt; solves another problem: Google's skill directory is massive, but it's impossible to preload everything.&lt;/p&gt;

&lt;p&gt;Its approach is to keep only a single page routing table and fetch the remote directory only when actually needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/google/skills/main/index.json &lt;span class="nt"&gt;-o&lt;/span&gt; index.json
&lt;span class="nv"&gt;$ &lt;/span&gt;jq &lt;span class="s1"&gt;'.skills | length'&lt;/span&gt; index.json
137
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; index.json
84476

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;137 skills, 84KB. Stuffing it all into the context is too wasteful, so the skill teaches you to filter with &lt;code&gt;jq&lt;/code&gt; before reading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/google/skills/main/index.json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.skills[] | select((.name+" "+.description)|test("cloud run";"i")) | .name'&lt;/span&gt;
cloud-run-basics
cloud-run-alert-configuration
cloud-monitoring-metric-selection
google-cloud-global-frontend-configuration
...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Select up to three of the most relevant ones, and only then fetch their entrypoints. This "remote directory, load on demand" pattern is, in my opinion, the most reasonable solution for handling a large number of skills currently.&lt;/p&gt;

&lt;p&gt;One more detail: this skill explicitly forbids using &lt;code&gt;curl -k&lt;/code&gt; to bypass certificate verification when authentication fails, even specifically naming PowerShell's &lt;code&gt;ServicePointManager&lt;/code&gt; callback. The reason given is excellent: "You are about to execute commands based on what you fetch; an unverified directory is worse than no directory at all."&lt;/p&gt;

&lt;h3&gt;
  
  
  Five Practical Scenarios
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Scenario 1: Connecting an agent to production, but only allowing it to look, not touch
&lt;/h4&gt;

&lt;p&gt;This is the one I wanted most. Previously, I didn't quite dare point Claude Code at a production project because it might suddenly decide to "clean up" a batch of resources. With the denylist, the boundaries become very clear.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Help me check which Cloud Run services in the production project haven't had a new revision in over three months.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It will list, filter, and organize the data into a table for you, but it will stop and ask when it encounters a delete operation. What it saves is the time spent flipping through documents and assembling commands; the decision-making power remains in your hands. This boundary is very well-defined.&lt;/p&gt;

&lt;h4&gt;
  
  
  Scenario 2: Starting a GCP project from scratch and deploying a LINE Bot to Cloud Run
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I want to start a new GCP project to deploy a LINE Bot. Help me go through the process from creating the project, linking billing, enabling APIs, to deploying to Cloud Run.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;google-cloud-recipe-onboarding&lt;/code&gt; will guide you through the entire sequence: billing → project → enabling APIs → deployment. It verifies syntax before each command and will definitely stop to ask you before enabling APIs (because it's on the denylist). This is perfect for people who only start a new project once in a long while and have to re-google everything each time.&lt;/p&gt;

&lt;h4&gt;
  
  
  Scenario 3: Credential Hell—Works locally, but 403 on Cloud Run
&lt;/h4&gt;

&lt;p&gt;This is probably the most common "hitting a wall" scenario in GCP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;My service runs fine locally, but after deploying to Cloud Run, calling Vertex AI keeps returning a 403. Help me clarify which credential is the issue.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;google-cloud-recipe-auth&lt;/code&gt; is responsible for clarifying the relationships and applicable scenarios for user credentials, ADC, and service accounts, while Developer Knowledge is responsible for looking up the precise IAM permission strings. Combined, these two are much faster than digging through five-year-old answers on Stack Overflow.&lt;/p&gt;

&lt;h4&gt;
  
  
  Scenario 4: Cloud Resource Inventory and Billing Health Check
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;List the names, URLs, and last deployment times of all Cloud Run services in this project, and identify those that might no longer be in use.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key here is that 35x difference measured earlier. Without guardrails, the raw JSON of 52 services would be enough to fill the agent's context with junk data before it even begins the actual analysis. With &lt;code&gt;--format&lt;/code&gt; projection and the habit of "probing schema with &lt;code&gt;--limit=1&lt;/code&gt; first," 52 services only consume 2k tokens, allowing it to handle several times more resources.&lt;/p&gt;

&lt;h4&gt;
  
  
  Scenario 5: Avoiding the model's old memories when asking about new features
&lt;/h4&gt;

&lt;p&gt;For products that iterate very quickly like Gemini API, Cloud Run, and Firebase, the model's training data is bound to be outdated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;How do I set min instances and startup CPU boost in Cloud Run now?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Developer Knowledge, the answer will include links to official documents and character-level citation ranges. More importantly, if it can't find the information, it will say so directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Presenting recalled documentation as a retrieved result is the worst available outcome, because nothing in the reply distinguishes it from a real lookup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;"I didn't find it; this is an answer from memory" is far more valuable than a flag that sounds smooth but is incorrect.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Summary
&lt;/h3&gt;

&lt;p&gt;From my testing, the things that left the deepest impression on me were actually two things unrelated to functionality.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;the entire package is 1549 lines of markdown, without a single line of code.&lt;/strong&gt; All security is implemented through text-based rules: verify syntax first, limit output, explicit denylist, and honest reporting of failure. This means you can also use the same method to write your own team's runbooks and security rules as skills: the barrier to entry is much lower than imagined.&lt;/p&gt;

&lt;p&gt;Second, four sets of manifests are squeezed into the same directory. Google didn't create a proprietary format for Antigravity; instead, they made the same package usable across Claude Code, Codex, and Gemini CLI. Skills are becoming a common format that isn't tied to a specific tool.&lt;/p&gt;

&lt;p&gt;If you have a GCP project and are using a coding agent, this plugin takes about five minutes to set up. Just for the fact that it won't hallucinate flags or touch IAM on its own, it's well worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reference Links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/docs/get-started/developer-environment?hl=en#antigravity-cli" rel="noopener noreferrer"&gt;Set up your Google Cloud developer environment&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/google/skills" rel="noopener noreferrer"&gt;google/skills - GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/google/skills/tree/main/plugins/cloud/google-cloud-developer" rel="noopener noreferrer"&gt;google-cloud-developer plugin source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://antigravity.google/docs/cli/" rel="noopener noreferrer"&gt;Google Antigravity CLI Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>cli</category>
      <category>google</category>
    </item>
    <item>
      <title>[AI in Action] Gemini Agentic Video: 4 Hidden Prerequisites and My LINE Bot Integration Process</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Sat, 26 Sep 2026 17:26:14 +0000</pubDate>
      <link>https://dev.to/gde/ai-in-action-gemini-agentic-video-4-hidden-prerequisites-and-my-line-bot-integration-process-582f</link>
      <guid>https://dev.to/gde/ai-in-action-gemini-agentic-video-4-hidden-prerequisites-and-my-line-bot-integration-process-582f</guid>
      <description>&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%2Fo9z46j02t965088inmj2.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%2Fo9z46j02t965088inmj2.png" alt="image-20260903123726193" width="800" height="706"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Background
&lt;/h1&gt;

&lt;p&gt;I have a LINE Bot that I use every day, &lt;a href="https://github.com/kkdai/linebot-helper-python" rel="noopener noreferrer"&gt;linebot-helper-python&lt;/a&gt;. If you send it a URL, it returns a summary and social media copy for four platforms; if you send a YouTube link, it returns a video summary. It also handles bookmarks, location queries, voice assistants, and more. It runs on Cloud Run and uses Vertex AI.&lt;/p&gt;

&lt;p&gt;In late August, Google published a post &lt;a href="https://blog.google/innovation-and-ai/models-and-research/gemini-models/introducing-agentic-video-in-gemini/" rel="noopener noreferrer"&gt;Introducing agentic video in Gemini&lt;/a&gt;. After reading it, my first thought was: this could allow my bot to do something it currently can't—&lt;strong&gt;ask questions about a video&lt;/strong&gt;, rather than just providing a summary.&lt;/p&gt;

&lt;p&gt;As it turned out, the parts not mentioned in the announcement were more worth documenting than the parts that were.&lt;/p&gt;




&lt;h1&gt;
  
  
  What is Agentic Video?
&lt;/h1&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%2Flqaxl9thmxphgubruz35.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%2Flqaxl9thmxphgubruz35.png" alt="image-20260903123748730" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Originally, Gemini's video reading was "static": no matter what you asked, it would cram every frame and every audio segment into the context at a fixed sampling rate. For a two-hour video, the video itself could take up hundreds of thousands of tokens, even if you just wanted to ask, "Did he mention pricing?"&lt;/p&gt;

&lt;p&gt;The Agentic mode flips this: it lets the model decide which segments to load. It first scans the transcript, determines that the answer might be around 1:24, and then only pulls in the frames for that specific segment.&lt;/p&gt;

&lt;p&gt;The official announcement cited figures for long-form video scenarios: 88% fewer tokens, 66% lower costs, and 7% higher accuracy. To enable it, you just add a parameter to the Part:&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;video_part&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;file_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FileData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;mime_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video/mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;media_processing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AGENTIC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# or "STATIC"
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three models are supported: &lt;code&gt;gemini-3.7-flash&lt;/code&gt;, &lt;code&gt;gemini-3.6-flash&lt;/code&gt;, and &lt;code&gt;gemini-3.5-flash-lite&lt;/code&gt;. Input sources can be YouTube URLs, Cloud Storage URIs, or embedded base64.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can it actually do?
&lt;/h2&gt;

&lt;p&gt;I tested it with the two-hour Google I/O ‘25 keynote, asking "Did he mention pricing? If so, please give me the timestamp." The response was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The video mentions information related to pricing and subscription plans. It mainly appears around &lt;strong&gt;1:24:40 to 1:26:10&lt;/strong&gt; in the video...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It then accurately described the differences between the Google AI Pro and Google AI Ultra plans. When I asked where the Android XR glasses segment was, it replied &lt;code&gt;1:36:31 to 1:50:30&lt;/code&gt;, and the content matched perfectly.&lt;/p&gt;

&lt;p&gt;This ability to precisely locate information within a two-hour video is where I think the real value of this feature lies. Anyone can do summaries, but "when in this long video did he talk about X" is something few tools currently do well.&lt;/p&gt;




&lt;h1&gt;
  
  
  Prerequisites: Four parameters, missing one won't trigger an error
&lt;/h1&gt;

&lt;p&gt;This was the most expensive lesson this time, so I'm putting it first.&lt;/p&gt;

&lt;p&gt;To make agentic video actually work on Vertex AI, &lt;strong&gt;four things must be true simultaneously&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;#&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;What happens if missing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api_version="v1beta1"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Agentic is only available in v1beta1; using &lt;code&gt;v1&lt;/code&gt; silently falls back to STATIC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;media_processing="AGENTIC"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Defaults to STATIC; if not set, it's not enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Model is on the supported list&lt;/td&gt;
&lt;td&gt;Unsupported models silently downgrade to STATIC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;thinking_level&lt;/code&gt; is set&lt;/td&gt;
&lt;td&gt;See the next section; this is the trickiest one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The keyword is &lt;strong&gt;silent&lt;/strong&gt;. If any of these are missing, the API will return a 200, the answer will still be generated, and everything will look perfectly normal—except for the bill. No error message will tell you that agentic mode didn't take effect.&lt;/p&gt;

&lt;p&gt;There are also two environmental prerequisites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SDK Version&lt;/strong&gt;: The &lt;code&gt;media_processing&lt;/code&gt; field was added in &lt;code&gt;google-genai&lt;/code&gt; &lt;strong&gt;2.20.0&lt;/strong&gt;. I verified this by downloading versions one by one; it's not in 2.19.0, but it is in 2.20.0. If the version is too old, this parameter is discarded as an unknown field, again without an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vertex Exclusive&lt;/strong&gt;: The multi-turn conversation examples in the official documentation are written for the Gemini Developer API. The behavior on Vertex is different, as I'll explain later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My approach was to write a test for each of these four items, asserting the parameters sent to the SDK:&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;test_thinking_level_is_low&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;captured&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;youtube_tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summarize_youtube_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;VIDEO_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;captured&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call_kwargs&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;config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thinking_config&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;assert&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;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thinking_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thinking_level&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOW&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;The only reason these four tests exist is that "if these four things are wrong, the program still runs, the answer still comes out, but the cost silently changes." This kind of bug cannot be caught by manual review.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 1: Video context cannot be preserved on Vertex AI
&lt;/h1&gt;

&lt;p&gt;My original design was this: the user pastes a link to get a summary, clicks "Ask about this video" to enter Q&amp;amp;A mode, and then every subsequent follow-up question uses the context from the previous round, avoiding the need to re-process the video.&lt;/p&gt;

&lt;p&gt;The official documentation indeed says this: the response will carry &lt;code&gt;tool_call&lt;/code&gt; / &lt;code&gt;tool_response&lt;/code&gt; parts; put them back into the history as-is, and the next round won't need to re-process the video.&lt;/p&gt;

&lt;p&gt;In practice, I couldn't get them.&lt;/p&gt;

&lt;p&gt;The parts returned by Vertex only contained a bare &lt;code&gt;thought_signature&lt;/code&gt;, with no &lt;code&gt;tool_call&lt;/code&gt; or &lt;code&gt;tool_response&lt;/code&gt;. Passing the history back resulted in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;400 INVALID_ARGUMENT: Invalid thought signature.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried three different serialization methods, all failed. Then I tried &lt;strong&gt;not serializing at all and passing the response object back directly&lt;/strong&gt;, which also failed. So the problem wasn't my serialization; it was the platform.&lt;/p&gt;

&lt;p&gt;After setting &lt;code&gt;thinking_level&lt;/code&gt; to &lt;code&gt;LOW&lt;/code&gt;, the error stopped, but the &lt;code&gt;tool_use_prompt_token_count&lt;/code&gt; for the second round was &lt;strong&gt;2,163&lt;/strong&gt;, exactly the same as the &lt;strong&gt;2,163&lt;/strong&gt; in the first round. The video was completely re-processed; no context was preserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause and Solution&lt;/strong&gt;: That multi-turn mechanism was written for the Gemini Developer API. Since it can't be preserved on Vertex, don't pretend it can: switch to a stateless re-query approach where every question is an independent call.&lt;/p&gt;

&lt;p&gt;This decision actually made the implementation simpler: no need to serialize history, no need to handle thought signatures, and the session only needs to store three fields: "which video is this user currently asking about." The complex version I originally planned was scrapped.&lt;/p&gt;

&lt;p&gt;By the way, I was originally worried that "history serialization might exceed the Firestore single-document 1 MiB limit." In reality, it was only 1.5–7 KiB, so &lt;strong&gt;it was never an issue&lt;/strong&gt;. The things you spend time worrying about are often not where the real problems occur.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 2: Thinking tokens are the main cost driver, and they are unstoppable
&lt;/h1&gt;

&lt;p&gt;I had to correct my causal inference three times to get this one right, and the process was more interesting than the conclusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1 Conclusion (Wrong)
&lt;/h2&gt;

&lt;p&gt;Initial spike, same 10-minute video:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;in&lt;/th&gt;
&lt;th&gt;out (incl. thinking)&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;STATIC&lt;/td&gt;
&lt;td&gt;54,546&lt;/td&gt;
&lt;td&gt;291&lt;/td&gt;
&lt;td&gt;$0.0171&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AGENTIC, no thinking_level set&lt;/td&gt;
&lt;td&gt;2,216&lt;/td&gt;
&lt;td&gt;37,574&lt;/td&gt;
&lt;td&gt;$0.0946&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AGENTIC + &lt;code&gt;thinking_level="LOW"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2,216&lt;/td&gt;
&lt;td&gt;638&lt;/td&gt;
&lt;td&gt;$0.0023&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It seemed clear: agentic mode removed the video from the input (54,546 → 2,216), but the model used "thinking" to navigate, and &lt;strong&gt;thinking tokens are billed as output&lt;/strong&gt; ($2.50/M, which is 8.3x the input price). So, not setting &lt;code&gt;thinking_level&lt;/code&gt; was 5.5x more expensive than static, but setting it made it 7.4x cheaper.&lt;/p&gt;

&lt;p&gt;Then I ran a two-hour video, also with &lt;code&gt;LOW&lt;/code&gt; set, and the thinking tokens were &lt;strong&gt;359,961&lt;/strong&gt;, costing $0.91 for a single call.&lt;/p&gt;

&lt;p&gt;My conclusion: "On long videos, &lt;code&gt;thinking_level&lt;/code&gt; is ignored."&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 2 Conclusion (Also Wrong)
&lt;/h2&gt;

&lt;p&gt;During the implementation phase, the agent responsible for that task ran a validation as I requested and hit a threshold, stopping to report: for the same video and the same settings, the thinking tokens for five consecutive runs were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0, 0, 34911, 35122, 37410
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It even pulled the DEBUG logs to confirm that every request sent indeed carried &lt;code&gt;v1beta1&lt;/code&gt; + &lt;code&gt;AGENTIC&lt;/code&gt; + &lt;code&gt;thinking_level=LOW&lt;/code&gt;, so it wasn't a client-side omission.&lt;/p&gt;

&lt;p&gt;So it wasn't the video length—&lt;strong&gt;I had run each setting only once and mistaken sampling noise for causality&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The agent proposed a hypothesis: it might be related to prompt complexity, because the previous batch used a one-sentence short prompt, while the problematic batch used the official long prompt. Reasonable, so I had it re-test with the official prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 3 (The Reality)
&lt;/h2&gt;

&lt;p&gt;27 calls, 6 times for each of the three settings plus 3 times for the Q&amp;amp;A path:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;thinking peak&lt;/th&gt;
&lt;th&gt;tool_use&amp;gt;0&lt;/th&gt;
&lt;th&gt;Average Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;thinking_level="LOW"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0/6&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;$0.00146&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;thinking_budget=0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0/6&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;$0.00137&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not set at all&lt;/td&gt;
&lt;td&gt;0/6&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;$0.00137&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;STATIC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0/6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✗ (=0)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.01659&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All zero peaks. But the previous batch using the &lt;strong&gt;same official prompt&lt;/strong&gt; had peaks in 3 out of 4 runs.&lt;/p&gt;

&lt;p&gt;So prompt complexity wasn't it either. The behavior of the three client-side settings was identical; the only difference was &lt;strong&gt;when they were called&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause and Solution&lt;/strong&gt;: This is server-side non-determinism, and the client has no leverage to control it. I also confirmed two things: &lt;code&gt;thinking_budget&lt;/code&gt; and &lt;code&gt;thinking_level&lt;/code&gt; cannot be used together (the server returns a 400); &lt;code&gt;STATIC&lt;/code&gt; is the only deterministic option, but its &lt;code&gt;tool_use_tokens&lt;/code&gt; is 0. That's not "the same feature in a stable mode," it's turning off agentic mode entirely.&lt;/p&gt;

&lt;p&gt;My decision was to keep &lt;code&gt;thinking_level="LOW"&lt;/code&gt; and add a warning log:&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="c1"&gt;# In testing, thinking tokens only fall into two groups: ~0 or ~35,000-37,000, with no values in between.
# The cause is server-side non-determinism, which no client setting can prevent.
&lt;/span&gt;&lt;span class="n"&gt;THINKING_TOKENS_WARN_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I kept LOW not because it's more stable (it isn't), but because there was no measurable difference between the three settings, and changing it would mean swapping verified behavior for unverified behavior. The truly valuable reinforcement was turning that 60x cost event from something invisible into something searchable in Cloud Run logs. If you can't stop it, at least make it visible.&lt;/p&gt;

&lt;p&gt;So the honest cost picture is: &lt;strong&gt;normally about $0.0014 per call, with unpredictable and unpreventable peaks about 60x higher&lt;/strong&gt;. The "66% cost reduction" mentioned in the announcement holds true when there's no peak, but flips when there is.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;thinking_level&lt;/code&gt; parameter in my implementation is &lt;strong&gt;required, with no default value&lt;/strong&gt;:&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;_generate_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;youtube_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thinking_level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Giving it a default value is just an invitation for someone to omit it, and omitting it leaves no trace.&lt;/p&gt;




&lt;h1&gt;
  
  
  Integrating into the LINE Bot: What the feature looks like
&lt;/h1&gt;

&lt;p&gt;The flow is as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User pastes a YouTube link
  → Receives summary + social media copy (existing feature)
  → A new button appears: "🎬 Ask about this video"
  → After clicking, the user types a question: "Did he mention pricing?"
  → Answer comes back with timestamps
  → User pastes a new URL → Automatically exits video mode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three new modules, each independently testable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tools/youtube_tool.py&lt;/code&gt; (modified) — The only place communicating with the Gemini video API, with the four required parameters centralized in one function.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;services/video_qa.py&lt;/code&gt; (new) — Remembers "User → Video" mapping, with a 30-minute TTL.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;services/usage_meter.py&lt;/code&gt; (new) — Records tokens and costs for each call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; only adds two integration points: a message interceptor and a postback branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision order is the behavioral contract
&lt;/h2&gt;

&lt;p&gt;When a user types in video mode, the decision order is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exit condition check → Quota check → Call Gemini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The exit check must come before the quota check.&lt;/strong&gt; If a user pastes a new URL to change the topic, they shouldn't be blocked because their video Q&amp;amp;A quota is exhausted. Those are two unrelated things. If the order is reversed, a person could get stuck in a mode where every message is rejected and they can't get out.&lt;/p&gt;

&lt;p&gt;This rule has its own dedicated test because it's a contract, not an implementation detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use heuristics for exiting mode, not LLM
&lt;/h2&gt;

&lt;p&gt;To determine if a user wants to leave video mode, I used the simplest method: if the message contains a URL or starts with &lt;code&gt;/&lt;/code&gt;, exit.&lt;/p&gt;

&lt;p&gt;I deliberately avoided using an LLM to judge intent because that would require an extra Gemini call for every message, and the cost of a false positive is low (the user just asks again). It's not worth the cost and latency.&lt;/p&gt;

&lt;p&gt;There's a detail worth noting in the implementation: this check directly calls the same &lt;code&gt;find_url()&lt;/code&gt; used by the main path. Initially, I wrote my own regex, but during review, someone suggested broadening it to support URLs without schemes (&lt;code&gt;www.youtube.com/...&lt;/code&gt;). I checked what the main path actually used and found its regex couldn't catch those either: &lt;strong&gt;broadening it unilaterally would only create a discrepancy&lt;/strong&gt;: video mode would exit, but the main path wouldn't treat it as a URL, leaving the user with a useless chat reply instead of a useless video reply.&lt;/p&gt;

&lt;p&gt;By switching to the shared function, the two are always consistent; if &lt;code&gt;find_url&lt;/code&gt; is broadened in the future, video mode will automatically follow suit.&lt;/p&gt;




&lt;h1&gt;
  
  
  Development Process: Using subagents to catch my mistakes
&lt;/h1&gt;

&lt;p&gt;For this implementation, I used Claude Code's subagents: a plan split into 8 tasks, with each task assigned to a fresh agent for implementation, followed by another agent for review. I only acted as the coordinator and arbitrator.&lt;/p&gt;

&lt;p&gt;The benefit of this arrangement is that &lt;strong&gt;the reviewer doesn't have the implementer's attachment&lt;/strong&gt;. An agent that just wrote the code is likely to think it's correct; a reviewer agent only sees the diff, the requirements, and the report, without the baggage of "I thought about this for a long time."&lt;/p&gt;

&lt;p&gt;The things they caught were far more valuable than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  They caught three of my incorrect causal inferences
&lt;/h2&gt;

&lt;p&gt;As mentioned in the thinking token section, the first correction happened when the implementation agent hit the threshold I set and stopped to report (I wrote "stop if you see over 30,000, don't just commit silently" in the instructions, and it followed them). The second was when it proposed the prompt complexity hypothesis and designed an experiment to rule it out.&lt;/p&gt;

&lt;p&gt;The third was the most interesting. While updating the documentation, I replaced the debunked theory with "&lt;code&gt;gemini-3.5-flash-lite&lt;/code&gt; is the only model with stable costs." The reviewer agent pointed out that the 27 non-deterministic experiments &lt;strong&gt;were run on 3.5-flash-lite&lt;/strong&gt;, and the peaks were measured on it, so that sentence directly contradicted the paragraph it was citing.&lt;/p&gt;

&lt;p&gt;I had replaced one baseless claim with another baseless claim, and I did it three times (in the spec, the config file comments, and the test docstrings).&lt;/p&gt;

&lt;h2&gt;
  
  
  It caught a Critical bug that missed eight rounds of review
&lt;/h2&gt;

&lt;p&gt;The final branch review caught one thing: &lt;strong&gt;the entry button for the entire feature wouldn't even show up&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The button was attached to a carousel message, but four text messages were appended afterward. LINE only renders the quickReply of the &lt;strong&gt;last message&lt;/strong&gt;, so the button was buried. A user pasting a YouTube link would receive the summary and four pieces of copy, but see no button.&lt;/p&gt;

&lt;p&gt;Eight rounds of task reviews had passed it because each round only saw "the button is correctly attached to the carousel"; no one saw that four more messages would be added later. The tests missed it too—those tests called the handler directly, skipping the message assembly part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is something a single-task review is structurally unable to see&lt;/strong&gt;; it only becomes visible when the entire path is laid out.&lt;/p&gt;

&lt;p&gt;After fixing it, I verified it again with mutation testing: moving the attachment back to the original position caused only the new multi-URL test to fail, while the original single-URL test still passed, which is exactly why it couldn't catch the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes I made myself
&lt;/h2&gt;

&lt;p&gt;No matter how good the process is, it can't stop me from writing the wrong things in the instructions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the design document and instructions for the agents, I wrote "if reply fails, it will switch to push," and even repeated it twice in conversation. In reality, that mechanism &lt;strong&gt;doesn't exist&lt;/strong&gt;: I had misread a sentence in a docstring describing "message overflow handling" as "failure fallback" and never verified it. The final branch review caught it.&lt;/li&gt;
&lt;li&gt;A code snippet I provided had an &lt;code&gt;UnboundLocalError&lt;/code&gt; where a variable was only assigned in one branch. The implementation agent corrected it.&lt;/li&gt;
&lt;li&gt;I told it to add the entry button in a &lt;strong&gt;place that didn't exist&lt;/strong&gt;: that postback handler was dead code; nothing in the repo would generate the corresponding button. It found the actual reachable path itself and attached it there.&lt;/li&gt;
&lt;li&gt;I requested "reverting numerical rounding" without first calculating how it related to existing test tolerances, causing that task to go in a circle. The implementation agent showed me the math instead of just picking a side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a bit embarrassing to write down, but these are exactly the real outputs of this process: &lt;strong&gt;every single error was caught before merging&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  Results and Benefits
&lt;/h1&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;Figures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Commits&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;192 → 275 passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Converged model literals&lt;/td&gt;
&lt;td&gt;28 places&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed online failures&lt;/td&gt;
&lt;td&gt;1 (Smart Dialog 404)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actual cost&lt;/td&gt;
&lt;td&gt;Approx. $6–8 (mostly on three rounds of cost measurement)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I also completed an item that had been on the roadmap for a long time: token and cost logging. This was originally "do it when there's time," but after the thinking token pitfall, it became a necessary component: since peaks can't be prevented, you must at least be able to see that they happened after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few things I'm taking away
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check constraints first, then design.&lt;/strong&gt; This time, almost every design decision was forced by constraints: Vertex not preserving context forced stateless queries, uncontrollable thinking tokens forced warning logs, and the inability to use &lt;code&gt;thinking_budget&lt;/code&gt; and &lt;code&gt;thinking_level&lt;/code&gt; together scrapped an entire option. It's much easier to check constraints thoroughly before starting than to design first and hit a wall later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A single observation is not a conclusion.&lt;/strong&gt; My most expensive mistake was taking one observation, making a causal judgment, and then writing that judgment into the design document, where it spread to code comments, environment variable descriptions, and decision records. By the time I realized it was wrong, it took three rounds just to clean up the residue—and I even managed to generate new incorrect theories in the middle. Overturning a conclusion is much more work than establishing one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests guard bug classes, not strings.&lt;/strong&gt; When the &lt;code&gt;gemini-3-pro-preview&lt;/code&gt; deprecation broke a flagship feature, if I had just changed the model name, it would have happened again next time. By changing it to "no preview models allowed," that entire class of problem was truly blocked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent failures deserve dedicated tests.&lt;/strong&gt; For those four required parameters, if any one is wrong, the program still runs and the answer still comes out. This won't show up in error logs or be caught by code reviews; only the bill will tell you—and by the time the bill tells you, a month has usually passed.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/kkdai/linebot-helper-python" rel="noopener noreferrer"&gt;kkdai/linebot-helper-python&lt;/a&gt;, and this implementation is in PR #20. The official documentation is &lt;a href="https://ai.google.dev/gemini-api/docs/video-understanding" rel="noopener noreferrer"&gt;Video understanding&lt;/a&gt;, but that version is for the Gemini Developer API; if you are using Vertex AI like me, treat the multi-turn conversation section as a reference, not a guarantee.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemini</category>
      <category>google</category>
      <category>python</category>
    </item>
    <item>
      <title>[Steam Deck Guide] Fix Windows .exe Patch Path Errors: The Ultra-Stable "Relay Transfer Method"</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Sat, 26 Sep 2026 17:25:46 +0000</pubDate>
      <link>https://dev.to/evanlin/steam-deck-guide-fix-windows-exe-patch-path-errors-the-ultra-stable-relay-transfer-method-me1</link>
      <guid>https://dev.to/evanlin/steam-deck-guide-fix-windows-exe-patch-path-errors-the-ultra-stable-relay-transfer-method-me1</guid>
      <description>&lt;p&gt;When playing various PC games on the Steam Deck, you often encounter situations where you need to install "community localization patches," "fan-made fix updates," or "MOD expansion packs." However, many patches released by developers or localization groups are packaged as Windows &lt;code&gt;.exe&lt;/code&gt; installers that require the player to manually select the game directory during execution.&lt;/p&gt;

&lt;p&gt;This often leads to a major pain point under Steam Deck's SteamOS (Linux-based architecture): &lt;strong&gt;The installer's folder selection window simply cannot find where the game is installed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article will analyze the core cause of this problem and share a "Relay Relocation Method" that is currently the most intuitive, has the highest success rate, and does not require entering complex commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do patch programs get "lost"?
&lt;/h3&gt;

&lt;p&gt;SteamOS uses a compatibility layer (Proton or Wine) to simulate a Windows environment to run programs. Two underlying mechanisms easily cause installers to get stuck:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Hidden Folder Restrictions&lt;/strong&gt;: By default, Steam installs all games in &lt;code&gt;/home/deck/.local/share/Steam/steamapps/common/&lt;/code&gt;. In Linux systems, folders starting with a dot (&lt;code&gt;.&lt;/code&gt;) are hidden folders. The "Browse Folder" browsers built into most Windows patches cannot recognize or display these hidden paths.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Sandbox Permission Isolation&lt;/strong&gt;: If you are used to using Bottles or certain Flatpak applications to launch &lt;code&gt;.exe&lt;/code&gt; files, these tools are subject to sandbox permission controls by default and cannot directly read deep system files of the user.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Core Solution: Three-Step Relay Relocation
&lt;/h3&gt;

&lt;p&gt;Instead of fighting with hidden folder settings and Linux commands, the cleanest approach is to first move the game folder to a &lt;strong&gt;non-hidden public directory (such as Downloads or Desktop)&lt;/strong&gt;, complete the patch injection, and then overwrite it back to the original path.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 1: Copy the game directory to a relay path
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt; Long-press the power button and switch to &lt;strong&gt;Switch to Desktop&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Open the Steam Library, right-click on the target game ➔ &lt;strong&gt;Manage&lt;/strong&gt; ➔ &lt;strong&gt;Browse local files&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; The file manager (Dolphin) will open the directory where the game is located. Click the address bar to go up one level back to the &lt;code&gt;common&lt;/code&gt; folder.&lt;/li&gt;
&lt;li&gt; Right-click on "the folder for that game" and select &lt;strong&gt;Copy&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Enter &lt;strong&gt;Downloads&lt;/strong&gt; or &lt;strong&gt;Desktop&lt;/strong&gt; from the left shortcut bar, right-click in a blank space, and select &lt;strong&gt;Paste One Folder&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  Step 2: Run the patch and select the path via the Z: drive
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt; Launch the patch &lt;code&gt;.exe&lt;/code&gt; via Bottles, Protontricks, or by adding the patch as a non-Steam game.&lt;/li&gt;
&lt;li&gt; When the installer pops up the "Please select the game installation directory" window, click open &lt;strong&gt;My Computer&lt;/strong&gt; ➔ &lt;strong&gt;&lt;code&gt;Z:&lt;/code&gt; drive&lt;/strong&gt; (&lt;code&gt;Z:\&lt;/code&gt; corresponds to the root directory of the Steam Deck).&lt;/li&gt;
&lt;li&gt; Expand the following public paths in order:

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;Z:\home\deck\Downloads&lt;/code&gt; (or &lt;code&gt;Desktop&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt; After selecting the folder, press confirm to start the patch writing process. Since the path is completely transparent, the installer can successfully extract the replacement files into it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  Step 3: Overwrite back to the original path and confirm the startup file
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt; After the patch installation is complete and the window is closed, return to the Downloads or Desktop directory from earlier.&lt;/li&gt;
&lt;li&gt; Enter the patched game folder, press &lt;code&gt;Ctrl + A&lt;/code&gt; to select all files, and right-click to select &lt;strong&gt;Copy&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Return to the original Steam game folder (Steam Library ➔ Right-click game ➔ Manage ➔ Browse local files).&lt;/li&gt;
&lt;li&gt; Right-click in a blank space and select &lt;strong&gt;Paste&lt;/strong&gt;. When the system prompts for overwriting, select &lt;strong&gt;Overwrite / Write Over (Overwrite All)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Critical Check&lt;/strong&gt;: Some patches generate a separate executable after installation (e.g., &lt;code&gt;Game_Patch.exe&lt;/code&gt; or &lt;code&gt;Game_ZH.exe&lt;/code&gt;). If this is the case, please back up and rename the original game launcher (e.g., &lt;code&gt;Game.exe&lt;/code&gt;) and rename the new executable generated by the patch back to the default Steam startup filename.&lt;/li&gt;
&lt;li&gt; Clean up temporary files: Once everything is confirmed to be working, you can delete the relay folder in Downloads or Desktop.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Notes and Troubleshooting Tips
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Linux Case Sensitivity&lt;/strong&gt;: Linux systems treat &lt;code&gt;Data&lt;/code&gt; and &lt;code&gt;data&lt;/code&gt; as completely different directories. If the folder extracted from the patch has different capitalization than the original game, please manually merge the contents to avoid generating dual folders that prevent the game from reading the patch resources.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Animation and Encoding Compatibility&lt;/strong&gt;: If you encounter missing text boxes, missing Japanese characters, or a black screen during opening animations after patching, change the game's &lt;strong&gt;Properties ➔ Compatibility&lt;/strong&gt; in Steam to use &lt;strong&gt;GE-Proton&lt;/strong&gt; (which can be installed via ProtonUp-Qt in the Discover store). This usually solves most issues with custom video decoders.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By using this "Relay Relocation" technique, you can accurately complete installations on the Steam Deck and enjoy the best gaming experience, regardless of how old the interface or how tricky the path of a Windows one-time patch program may be.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>software</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>[AI in Practice] Gemini 3.8 Flash TTS Launch: I built a "Learn Japanese with MVs" Web App and burned through my daily quota.</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Sat, 26 Sep 2026 17:25:33 +0000</pubDate>
      <link>https://dev.to/gde/ai-in-practice-gemini-38-flash-tts-launch-i-built-a-learn-japanese-with-mvs-web-app-and-4o79</link>
      <guid>https://dev.to/gde/ai-in-practice-gemini-38-flash-tts-launch-i-built-a-learn-japanese-with-mvs-web-app-and-4o79</guid>
      <description>&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%2Fdyoz8fkpd3ndyiblz01v.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%2Fdyoz8fkpd3ndyiblz01v.png" alt=" " width="800" height="499"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Recap
&lt;/h1&gt;

&lt;p&gt;Every time I see a new Gemini feature, my first thought is "Can I connect it to my LINE Bot?"&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://ai.google.dev/gemini-api/docs/changelog" rel="noopener noreferrer"&gt;Gemini API release notes&lt;/a&gt; from 9/22 stated that &lt;strong&gt;Gemini 3.8 Flash TTS and Gemini 3.8 Flash-Lite TTS are officially launched (GA)&lt;/strong&gt;, and the official blog simultaneously posted &lt;a href="https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-8-text-to-speech/" rel="noopener noreferrer"&gt;Gemini 3.8 Flash TTS and Gemini 3.8 Flash-Lite TTS&lt;/a&gt;. As usual, I listed a bunch of LINE Bot ideas: bedtime stories in parents' voices, turning group chats into radio dramas, morning dual-host podcasts...&lt;/p&gt;

&lt;p&gt;Halfway through the list, I realized that what actually struck me most wasn't a bot, but the fact that "&lt;strong&gt;directing tone sentence-by-sentence&lt;/strong&gt;" is particularly suitable for teaching pronunciation. So the topic changed to a Web App:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick a song, get the lyrics, and translate them into Chinese simultaneously.&lt;/li&gt;
&lt;li&gt;If it's Japanese or Korean, attach the phonetic transcription, then transform into a language teacher to teach you how to read it sentence by sentence.&lt;/li&gt;
&lt;li&gt;Turn it into a "learning languages through songs" tool.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As it turned out, there was nothing to complain about regarding the quality of the TTS itself; what really took time were the things surrounding it that weren't in the documentation.&lt;/p&gt;




&lt;h1&gt;
  
  
  What is Gemini 3.8 Flash TTS
&lt;/h1&gt;

&lt;p&gt;Two models were released in this GA:&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;Positioning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-3.8-flash-tts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flagship model, emphasizes vocal performance and character creation, allows sentence-by-sentence performance control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-3.8-flash-lite-tts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cheap, fast, suitable for high-volume generation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Compared to the previous generation, there are three things I find truly useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Voice design&lt;/strong&gt;: Generate a voice using a text description, for example, "a 60-year-old British-accented astronomer with a warm tone." The generated voice gets a &lt;code&gt;voice_id&lt;/code&gt; for repeated use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice replication&lt;/strong&gt;: Replicate a person's voice using a 10–30 second recording. The prerequisite is that the voice owner must record a consent statement, and the output audio will carry a SynthID watermark and C2PA markers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentence-by-sentence performance control&lt;/strong&gt;: Every segment of text can include a &lt;code&gt;style&lt;/code&gt; (e.g., "speak slowly, pronounce every syllable clearly"), and it supports tags like &lt;code&gt;&amp;lt;laughs&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;sigh&amp;gt;&lt;/code&gt;, as well as dialogue between up to two people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The actual call structure is different from the past &lt;code&gt;generate_content&lt;/code&gt;, moving to two sets of APIs: &lt;code&gt;interactions&lt;/code&gt; and &lt;code&gt;voices&lt;/code&gt;:&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="c1"&gt;# Design a voice using text description (one-time, save for reuse)
&lt;/span&gt;&lt;span class="n"&gt;voice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;voices&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="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;voice&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;model&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;gemini-3.8-flash-tts&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;type&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;prompted&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;display_name&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;Song Lingo Japanese Teacher&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;gender&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;female&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;language_code&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;ja-JP&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;prompted&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;input&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;A warm, patient Japanese language teacher in her early 30s from Tokyo...&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="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Use this voice to read a sentence, with style controlling tone and speed
&lt;/span&gt;&lt;span class="n"&gt;interaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interactions&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="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-3.8-flash-tts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;input&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;type&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;user_input&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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="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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annotations&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;type&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;speech_metadata&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;style&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;speaking slowly and clearly&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="p"&gt;}],&lt;/span&gt;
    &lt;span class="n"&gt;response_format&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;type&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;audio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;generation_config&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;speech_config&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;voice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&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;A few technical details to know beforehand that will save a lot of trouble:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Specification&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Output Format&lt;/td&gt;
&lt;td&gt;WAV, 16-bit PCM, mono, 24 kHz; also supports streaming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Languages&lt;/td&gt;
&lt;td&gt;Over 100 types, including Simplified Chinese, Traditional Chinese, Cantonese; &lt;strong&gt;Taiwanese was not seen&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Voice Limit&lt;/td&gt;
&lt;td&gt;200 per project, kept for 1 year; or choose self-managed &lt;code&gt;voicekey_&lt;/code&gt;, valid for 7 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice replication Regional Restrictions&lt;/td&gt;
&lt;td&gt;Not available in Illinois, Texas, EEA, UK, Switzerland, India&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;Not mentioned in announcements or docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tier 1 Quota&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;100 requests per day&lt;/strong&gt; (this becomes the main character later)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h1&gt;
  
  
  Things to Handle Before Starting: Lyrics Copyright
&lt;/h1&gt;

&lt;p&gt;"Enter song name, automatically crawl lyrics" is the most intuitive way, but it gets stuck in two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copyright&lt;/strong&gt;: Lyrics sites pay for licenses to display them. Crawling them to display full text and adding translations (legally considered a derivative work) carries infringement risks if launched publicly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Technical&lt;/strong&gt;: Even using Gemini with Google Search, Gemini has mechanisms to prevent reproducing copyrighted text, often returning &lt;code&gt;finishReason: RECITATION&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I thought of another way: &lt;strong&gt;Throw the YouTube MV URL directly to Gemini and ask it to transcribe the lyrics.&lt;/strong&gt; Technically feasible, as the Gemini API accepts public YouTube URLs.&lt;/p&gt;

&lt;p&gt;But let's be clear: &lt;strong&gt;Obtaining lyrics in a different way doesn't make it legal.&lt;/strong&gt; Whether crawled from a site, typed manually, or transcribed by AI from an MV, it's the same protected text. What determines the risk is how you use it.&lt;/p&gt;

&lt;p&gt;So my decision was: This is a personal learning tool; lyrics only exist in the local &lt;code&gt;output/&lt;/code&gt; folder, and this folder was added to &lt;code&gt;.gitignore&lt;/code&gt; from the first commit, never appearing on GitHub. During development, I also required Claude Code to &lt;strong&gt;only print statistics&lt;/strong&gt; when verifying data, never printing lyrics to the terminal or writing them to any logs.&lt;/p&gt;




&lt;h1&gt;
  
  
  Architecture: Three Python Scripts + One Next.js
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;YouTube URL
  ─transcribe.py─▶ Lyrics and timeline
  ─annotate.py──▶ Phonetics, translation, word breakdown, grammar points
  ─speak.py────▶ Teacher demonstration (normal / slow)
  ─Next.js─────▶ Learn sentence-by-sentence with MV

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Transcription: Prioritizing On-Screen Subtitles for Gemini
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;transcribe.py&lt;/code&gt; sends the YouTube URL as &lt;code&gt;file_data&lt;/code&gt; to &lt;code&gt;gemini-3.8-flash&lt;/code&gt;, using structured output to require start/end times, original text, Hiragana for Japanese, and an "unclear" flag for each sentence.&lt;/p&gt;

&lt;p&gt;The most critical line in the prompt: &lt;strong&gt;If there are lyric subtitles on the MV screen, prioritize using the subtitles.&lt;/strong&gt; Transcribing singing is much harder than speech due to accompaniment, elongated notes, and harmonies; Japanese also has homophone issues (hearing "kimi" without knowing if it's "君" or "きみ"). With subtitles, Gemini looks at the screen and listens simultaneously, greatly improving accuracy.&lt;/p&gt;

&lt;p&gt;I tested with three songs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Song&lt;/th&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Lyrics Source&lt;/th&gt;
&lt;th&gt;Sentence Count (Unique)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Yuuri 〈Betelgeuse〉&lt;/td&gt;
&lt;td&gt;Japanese&lt;/td&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;40 (23)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yuuri 〈Christmas Eve〉&lt;/td&gt;
&lt;td&gt;Japanese&lt;/td&gt;
&lt;td&gt;Screen Subtitles&lt;/td&gt;
&lt;td&gt;45 (35)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Take That 〈Back for Good〉&lt;/td&gt;
&lt;td&gt;English&lt;/td&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;37 (26)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The structure was complete: no empty sentences, no backward time, no continuous repetition of the same sentence (a common symptom of model errors). The only thing that made me uneasy was that &lt;strong&gt;every sentence was marked as "certain"&lt;/strong&gt;, even for the two based on audio. The model is too confident; I'll fix this later with another method.&lt;/p&gt;

&lt;h2&gt;
  
  
  Annotation: Don't Leave All Phonetics to the LLM
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;annotate.py&lt;/code&gt; adds Traditional Chinese translation, word-by-word breakdown (reading, POS, meaning), a grammar point, and a pronunciation tip to each sentence. Choruses repeat, so Gemini is only called for &lt;strong&gt;unique sentences&lt;/strong&gt;, saving about 30-40%.&lt;/p&gt;

&lt;p&gt;Phonetics is the most interesting part of this section. My original idea was to use existing libraries, which are more reliable than LLMs, but both failed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pykakasi&lt;/strong&gt; (Japanese) transliterates the particle "は" as &lt;code&gt;ha&lt;/code&gt;, when the correct pronunciation is &lt;code&gt;wa&lt;/code&gt;. Just looking at Hiragana, it can't tell if "は" is a particle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;korean-romanizer&lt;/strong&gt; (Korean) transliterates "감사합니다" as &lt;code&gt;gamsahapnida&lt;/code&gt;. The official Revised Romanization follows actual pronunciation, which should be &lt;code&gt;gamsahamnida&lt;/code&gt;; this package doesn't handle nasalization.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final division of labor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Japanese&lt;/strong&gt;: Gemini handles word segmentation and provides reading/POS for each word; the program then uses pykakasi to convert to Romaji and corrects particles は, へ, を to wa, e, o based on POS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Korean&lt;/strong&gt;: Hangul is phonetic; I simply asked Gemini to output according to Revised Romanization rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Libraries are good at "deterministic conversion" but bad at "judgment requiring context"; LLMs are the opposite. Handing the parts requiring understanding (segmentation, POS) to the LLM and the deterministic parts (Kana to Romaji) to the program uses both where they excel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-checking with Two Independent Results
&lt;/h2&gt;

&lt;p&gt;As mentioned, the transcription results marked everything as "certain," which isn't very believable. My fix: Gemini already gave a full-sentence reading during transcription, and then gave individual word readings during annotation. &lt;strong&gt;These two were generated separately; sentences that don't match are likely Kanji misreadings.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In practice, each of the two Japanese songs had 1 mismatch (1/23, 1/35). The program adds &lt;code&gt;needs_review&lt;/code&gt; to them, so the webpage can remind the user to proofread.&lt;/p&gt;

&lt;p&gt;This trick doesn't cost any extra API calls; it just compares two existing results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teacher: "Designed" via Voice Design
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;speak.py&lt;/code&gt; designs a teacher for each language, e.g., for Japanese: "A gentle and patient Japanese teacher from Tokyo in her early 30s." Created once, &lt;code&gt;voice_id&lt;/code&gt; saved for reuse. Two audio clips per sentence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Normal Speed&lt;/th&gt;
&lt;th&gt;Slow Speed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Length&lt;/td&gt;
&lt;td&gt;2.5–6.1s, avg 4.3s&lt;/td&gt;
&lt;td&gt;4.4–9.7s, avg 7.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow / Normal&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;At least 1.24x, up to 2.34x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Slow speed relies solely on a &lt;code&gt;style&lt;/code&gt; description without adjusting playback speed, and it's "speaking slowly and clearly," not stretching a normal speed clip. This is what impressed me most about this TTS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Web App: Next.js 16
&lt;/h2&gt;

&lt;p&gt;The webpage uses Next.js, with the server-side reading data directly from &lt;code&gt;../output/&lt;/code&gt;. The screen shows the embedded MV on the left, teaching cards below (Kana/Romaji/Translation/Breakdown/Grammar/Tips above Kanji, plus "Teacher," "Slow," "Original" buttons); the right side is the sentence list, auto-switching with the MV.&lt;/p&gt;

&lt;p&gt;Upon running &lt;code&gt;create-next-app&lt;/code&gt;, I encountered something interesting. The generated &lt;code&gt;AGENTS.md&lt;/code&gt; wrote in the first line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is NOT the Next.js you know&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Meaning this version has breaking changes, and agents should read &lt;code&gt;node_modules/next/dist/docs/&lt;/code&gt; before coding. Claude Code followed suit; &lt;code&gt;params&lt;/code&gt; became Promises, and &lt;code&gt;RouteContext&lt;/code&gt; types for route handlers were found there. Documentation written specifically for AI by frameworks is a practice I think will become increasingly common.&lt;/p&gt;




&lt;h1&gt;
  
  
  Quota: Tier 1 is Only 100 Times Per Day
&lt;/h1&gt;

&lt;p&gt;This is the most important constraint of the project, so I'll mention it first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gemini-3.8-flash-tts&lt;/code&gt; in Tier 1 has &lt;strong&gt;100 requests per day&lt;/strong&gt;. A song with 30+ sentences, each with normal and slow versions, takes about 50–70 requests. &lt;strong&gt;At this rate, only one or two songs can be processed per day.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I changed the batch generation in &lt;code&gt;speak.py&lt;/code&gt; to &lt;strong&gt;generate only when the user clicks play&lt;/strong&gt;, then cache it:&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;Consumes Gemini?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Viewing lyrics, translation, breakdown&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playing MV, clicking "Original"&lt;/td&gt;
&lt;td&gt;No, that's YouTube&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playing cached demo&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;First time&lt;/strong&gt; playing a demo&lt;/td&gt;
&lt;td&gt;1 TTS call (Normal and Slow counted separately)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding new song&lt;/td&gt;
&lt;td&gt;~2 Flash calls, no TTS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Audio files are named using the hash of the sentence content. Repeated choruses naturally share the same audio. Quota is only spent on sentences actually practiced.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 1: Program "Stuck," Actually Sleeping for Seven Hours
&lt;/h1&gt;

&lt;p&gt;While batch generating for the second song, progress stopped at 56/70.&lt;/p&gt;

&lt;p&gt;The status was strange: Python process alive, CPU 0%, 4 workers waiting on Google servers. First guess was no timeout, so I added 60s timeout and retries. After restarting, only 1 segment was added in ten minutes, and this time &lt;strong&gt;there wasn't even a network connection&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Turning off SDK auto-retry and looking at the response revealed the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;429 Rate limit exceeded for model gemini-3.8-flash-tts
(limit: 100 requests per day on Tier 1). Please retry in 7h12m16s
retry-after: 25936

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Daily quota was exhausted, and the SDK, upon receiving a 429, &lt;strong&gt;obediently waited 25,936 seconds to retry according to &lt;code&gt;Retry-After&lt;/code&gt;&lt;/strong&gt;. From the outside, it looked like a program that consumed no CPU, had no network, and would never end.&lt;/p&gt;

&lt;p&gt;Worse, my initial attempt to use &lt;code&gt;HttpRetryOptions(attempts=1)&lt;/code&gt; to disable retries failed completely. The reason is that the &lt;code&gt;interactions&lt;/code&gt; API uses a different HTTP client in the SDK (module name &lt;code&gt;_gaos&lt;/code&gt;), which ignores &lt;code&gt;HttpRetryOptions&lt;/code&gt;. I had to change its own config:&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# This API doesn't respect HttpRetryOptions; if not disabled, it sleeps for hours on daily quota limits
&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interactions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sdk_configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: "Respecting &lt;code&gt;Retry-After&lt;/code&gt;" is good for per-minute limits but a disaster for daily quotas. Now the program handles retries itself: transient errors retry up to 3 times; if a 429 contains "per day," all workers stop immediately, print the wait time, and keep existing audio files.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 2: Fields in Python SDK Don't Exist in REST
&lt;/h1&gt;

&lt;p&gt;This was the most expensive lesson.&lt;/p&gt;

&lt;p&gt;After switching to "generate on play," the work moved from Python to Next.js server-side using &lt;code&gt;fetch&lt;/code&gt; for REST. Python version:&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;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TS version followed suit with &lt;code&gt;json.output_audio?.data&lt;/code&gt;. The problem: &lt;strong&gt;The &lt;code&gt;output_audio&lt;/code&gt; field doesn't exist in the REST response at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Checking the SDK source code revealed that &lt;code&gt;output_audio&lt;/code&gt; is a &lt;strong&gt;convenience field calculated by the Python SDK&lt;/strong&gt; in a pydantic validator: it looks for a step with &lt;code&gt;type: "model_output"&lt;/code&gt; in the &lt;code&gt;steps&lt;/code&gt; array, then extracts the item with &lt;code&gt;type: "audio"&lt;/code&gt; from that step's &lt;code&gt;content&lt;/code&gt;. The real REST response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model_output"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"audio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;base64&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So this happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Gemini &lt;strong&gt;successfully generated audio every time, counting against the quota&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;My program couldn't read the audio, returned 502, and didn't save the file.&lt;/li&gt;
&lt;li&gt;Every time a user clicked play, plus multiple Range requests from the browser, &lt;strong&gt;TTS was called again&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Within about an hour, the 100-request quota was exhausted, and not a single new file was in &lt;code&gt;output/audio/&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To make matters worse, when this code went live, the daily quota was already exhausted, so the success format couldn't be verified. Claude Code noted "success path unverified" in the report, but we let it go live anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Fixed in two layers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parse according to SDK logic&lt;/strong&gt;: Look for &lt;code&gt;steps&lt;/code&gt;, then the old &lt;code&gt;outputs&lt;/code&gt; format, and finally &lt;code&gt;output_audio&lt;/code&gt;. No quota to verify via API, so I fed the same mock response to the Python SDK parser and the new TS function to ensure consistency. Later, when a quota slot opened, the first English audio was generated and saved (5.04s, 24 kHz), finally verifying it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prevent double billing on failure&lt;/strong&gt;: For 60 seconds after a failed generation, return the last error directly; after a 429, block all audio until the &lt;code&gt;Retry-After&lt;/code&gt; time.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A failed generation may still have been billed, so don't let repeated clicks or the&lt;/span&gt;
&lt;span class="c1"&gt;// browser's parallel range requests retry it: replay the error for a while instead.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recentFailures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TtsError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;until&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this bug taught me: &lt;strong&gt;Failure doesn't mean no cost.&lt;/strong&gt; As long as the request reaches the model and the model runs, even if your program fails at the last parsing step, the bill still counts. Programs that consume quota must see a real success response before going live, and failure paths should default to "this might have already been billed."&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 3: Quotes in &lt;code&gt;.env&lt;/code&gt;
&lt;/h1&gt;

&lt;p&gt;Next.js first API call returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;400 API key not valid. Please pass a valid API key.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same key worked fine in Python. Checking the &lt;code&gt;.env&lt;/code&gt; format (length and start/end chars only) revealed my key was &lt;code&gt;GEMINI_API_KEY="..."&lt;/code&gt; &lt;strong&gt;with double quotes&lt;/strong&gt;. Python's &lt;code&gt;python-dotenv&lt;/code&gt; automatically strips quotes; the Node-side reader didn't, sending the quotes as part of the key.&lt;/p&gt;

&lt;p&gt;Also caught another issue: Gemini's error response is sometimes an array &lt;code&gt;[{ "error": ... }]&lt;/code&gt; instead of an object, so the original program couldn't even read the error message, showing a blank "400:" on screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Changed &lt;code&gt;.env&lt;/code&gt; reading logic to match &lt;code&gt;python-dotenv&lt;/code&gt; (allow &lt;code&gt;export&lt;/code&gt; prefix, strip quotes), and handled both error formats. A small thing, but these "one side handles it for you" differences are easy to miss when sharing a config file between languages.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 4: Naming Audio by Line Number Causes Misalignment on Proofreading
&lt;/h1&gt;

&lt;p&gt;Transcription errors are inevitable, so I added a proofreading interface: edit text, reading, or translation directly, or click "✓ OK"; after editing text, "Re-analyze whole song" reruns &lt;code&gt;annotate.py&lt;/code&gt; (1 Flash request) while preserving manual edits.&lt;/p&gt;

&lt;p&gt;Before starting, I found a problem: Audio files were named by "index of unique sentence," e.g., &lt;code&gt;028_normal.wav&lt;/code&gt;. &lt;strong&gt;If a lyric line is edited, subsequent sentence numbers might shift, causing existing audio to map to the wrong sentence.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Changed to naming by the hash of the sentence content:&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;clip_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Clips are keyed by line content so editing a lyric only invalidates that line&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s audio.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Editing one line only invalidates that line's audio; others are unaffected. All 103 generated audio files were moved to new filenames, so no quota was wasted. Python and TS both calculated the hash for the same Japanese segment to ensure consistency before going live.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 5: Some MVs Don't Allow Embedding
&lt;/h1&gt;

&lt;p&gt;Found some videos wouldn't play. YouTube returns error 101 or 150 when "owner does not allow playback on other sites."&lt;/p&gt;

&lt;p&gt;Now when these errors are detected, the video area swaps to a thumbnail with an explanation and an "Open on YouTube (start from here)" button; "Original" also opens a new tab at that timestamp. Teacher demos and teaching cards remain available.&lt;/p&gt;

&lt;p&gt;A small pitfall with thumbnails: High-res &lt;code&gt;maxresdefault.jpg&lt;/code&gt; isn't available for every video, but YouTube &lt;strong&gt;doesn't return 404; it returns a 120x90 gray default image&lt;/strong&gt;, so &lt;code&gt;&amp;lt;img onError&amp;gt;&lt;/code&gt; doesn't trigger, resulting in a large gray area.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Check image width after loading; if under 121 pixels, swap to the guaranteed &lt;code&gt;hqdefault.jpg&lt;/code&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  Development Workflow: Let Claude Code Verify, but Don't Let It See Lyrics
&lt;/h1&gt;

&lt;p&gt;The whole project was done with Claude Code, from checking changelogs and discussing product direction to coding and deployment planning. A few things I think we did right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;邊做邊推上 GitHub&lt;/strong&gt;: Commit and push at every stage, explaining "why" in messages, not just "what."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;驗證只看統計數字&lt;/strong&gt;: Checking transcription quality via sentence counts, time flow, repetitions, Kanji in readings; checking audio via length, slow-motion ratio, anomalies. No need to print lyrics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;測試用假歌曲&lt;/strong&gt;: Use a fake song for testing proofreading, re-analysis, and embedding errors (e.g., "駅まで歩く"), deleting it after testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;用無頭 Chrome 實際點按鈕&lt;/strong&gt;: Server-side tests aren't enough; used Puppeteer for play, proofread, and add flows. The "no sound" issue was narrowed down to the server-side after confirming Chrome playback was fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There were mistakes too, as mentioned in the pitfalls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Going live with quota-consuming code before verifying success format (Pitfall 2).&lt;/li&gt;
&lt;li&gt;Background "Add Song" status stayed in memory; if files were deleted, it would say "Complete" without redoing. Caught this while writing "resume from break" tests; changed to rely on disk files.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Results and Benefits
&lt;/h1&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%2Fjgsoxxb3lgazwzb1eivy.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%2Fjgsoxxb3lgazwzb1eivy.png" alt=" " width="800" height="589"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Number&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;commit&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Songs added&lt;/td&gt;
&lt;td&gt;3 (2 Japanese, 1 English)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Demo clips generated&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single demo generation time&lt;/td&gt;
&lt;td&gt;~5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding a new song&lt;/td&gt;
&lt;td&gt;~48s (Transcribe 14s, Analyze 34s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API usage per song&lt;/td&gt;
&lt;td&gt;~2 Flash calls, demos generated on demand&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Current features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;YouTube URL addition, background transcription and analysis.&lt;/li&gt;
&lt;li&gt;Sentence-by-sentence learning: Kana, Romaji, Translation, Breakdown, Grammar, Tips.&lt;/li&gt;
&lt;li&gt;Teacher demos, normal and slow, generated on first play.&lt;/li&gt;
&lt;li&gt;Proofreading interface: Edit lyrics, one-click confirm, re-analyze whole song.&lt;/li&gt;
&lt;li&gt;Fallback UI for non-embeddable videos.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next Step: Move to Cloud Run
&lt;/h2&gt;

&lt;p&gt;Currently only runs locally. Plan to deploy to GCP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Run&lt;/strong&gt; for Next.js, containerized with Python and &lt;code&gt;uv&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GCS&lt;/strong&gt; for audio and song data. Use signed URLs for playback; GCS supports Range, so Safari works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret Manager&lt;/strong&gt; for API keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAP&lt;/strong&gt; to lock the service to my Google account. This is essential: public access would drain my quota, and full lyrics/translations would move beyond "personal learning."&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;SDK convenience fields are not part of the API.&lt;/strong&gt; &lt;code&gt;output_audio&lt;/code&gt; is Python-only. Check real responses when switching languages or using REST.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Respecting Retry-After" depends on the limit type.&lt;/strong&gt; Good for per-minute, bad for daily. Fail fast and explain why for quota errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failed requests might have been billed.&lt;/strong&gt; Assume cost on failure for paid operations and prevent retry storms. A 60s failure cache would have saved a day's quota.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use LLMs and deterministic programs where they excel.&lt;/strong&gt; Gemini for segmentation/POS, pykakasi for Kana-to-Romaji. Cross-check independent results to find errors for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Think through copyright before the first line of code.&lt;/strong&gt; Different acquisition methods don't change legality. Use &lt;code&gt;.gitignore&lt;/code&gt;, stats-only verification, and IAP to keep it a personal tool.&lt;/p&gt;

&lt;p&gt;Code at &lt;a href="https://github.com/kkdai/song-lingo" rel="noopener noreferrer"&gt;kkdai/song-lingo&lt;/a&gt; (lyrics data not in repo). Official docs for &lt;a href="https://ai.google.dev/gemini-api/docs/speech-generation" rel="noopener noreferrer"&gt;Speech generation&lt;/a&gt; and &lt;a href="https://ai.google.dev/gemini-api/docs/voice-design" rel="noopener noreferrer"&gt;Voice design&lt;/a&gt;. Reminder for REST users: audio is in &lt;code&gt;steps[].content[]&lt;/code&gt;, not &lt;code&gt;output_audio&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>gemini</category>
      <category>llm</category>
    </item>
    <item>
      <title>[AI in Practice] Deploying Song Lingo to Cloud Run: Making a Private Lyrics Website Just for Me</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Sat, 26 Sep 2026 17:24:45 +0000</pubDate>
      <link>https://dev.to/gde/ai-in-practice-deploying-song-lingo-to-cloud-run-making-a-private-lyrics-website-just-for-me-mb3</link>
      <guid>https://dev.to/gde/ai-in-practice-deploying-song-lingo-to-cloud-run-making-a-private-lyrics-website-just-for-me-mb3</guid>
      <description>&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%2F2602skxzueeznvhx2xi2.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%2F2602skxzueeznvhx2xi2.png" alt=" " width="800" height="589"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Recap
&lt;/h1&gt;

&lt;p&gt;In &lt;a href="https://dev.to/evanlin/ai-shi-zhan-gemini-38-flash-tts-zheng-shi-tui-chu-wo-yong-ta-zuo-liao-ge-gen-zhu-mv-xue-ri-wen-de-web-appran-hou-ba-tian-de-e-du-shao-guang-liao-2hmd-temp-slug-2564154"&gt;the previous post&lt;/a&gt;, I used Gemini 3.8 Flash TTS to build Song Lingo: you paste a YouTube MV URL, Gemini transcribes the lyrics, adds furigana, translation, and grammar notes, and then a teacher designed via voice design reads it to you line by line.&lt;/p&gt;

&lt;p&gt;It has only ever run on my own computer, but I want to be able to use it on my phone. So the goal of this post is simple: &lt;strong&gt;Move it to Cloud Run so it can be used on mobile.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;However, this website has a unique characteristic: the page contains full lyrics and translations.&lt;/p&gt;




&lt;h1&gt;
  
  
  Requirement: How accurate does "Only I can see it" need to be?
&lt;/h1&gt;

&lt;p&gt;When a typical side project is deployed, it's at most a bit embarrassing if others see it. Song Lingo is different; making it public has two practical consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copyright&lt;/strong&gt;: I spent a whole section in the last post explaining that this is a personal learning tool, and the lyrics only exist on my own computer. Once the site is public, the nature changes from "personal learning" to "providing lyrics to the public."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: Adding a song calls Gemini Flash, and playing the demo audio for the first time calls TTS. If anyone can use it, it means anyone can spend my quota.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the goal isn't just to "have a login function," but to &lt;strong&gt;ensure at every layer, from start to finish, that only my account can access the lyrics and APIs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I first compared two solutions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;A. IAP + In-app Verification&lt;/th&gt;
&lt;th&gt;B. No external access, use &lt;code&gt;gcloud run services proxy&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Supported Devices&lt;/td&gt;
&lt;td&gt;Any browser, including mobile&lt;/td&gt;
&lt;td&gt;Only computers logged into &lt;code&gt;gcloud&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup Difficulty&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Potential for Error&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Lowest, no public entry point at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Option B is the safest, but it doesn't work on mobile, which is the whole reason for this deployment. So I chose A.&lt;/p&gt;




&lt;h1&gt;
  
  
  Vulnerability in Version 1: Signed URLs
&lt;/h1&gt;

&lt;p&gt;My initial plan was this: store audio files in GCS, and when playing, the API generates a short-lived &lt;strong&gt;signed URL&lt;/strong&gt; and redirects the browser to it. The advantage is that the audio doesn't pass through Cloud Run, saving bandwidth, and GCS itself supports Range requests.&lt;/p&gt;

&lt;p&gt;Halfway through writing, while reconsidering "how to accurately ensure only I can see it," I realized this was a vulnerability:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Within the expiration period, anyone who gets the signed URL can download it directly, completely bypassing IAP.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is essentially an anonymous bearer token. If the URL appears in browser history, is pasted somewhere, or is recorded by an extension, others can bypass all the previous login checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: "Only I can access" is a chain; its strength depends on the weakest link. The final approach was &lt;strong&gt;not to use signed URLs&lt;/strong&gt;. Audio files are always read by Cloud Run and then sent to the browser, and every playback must first pass IAP and in-app verification. An audio segment is about 250KB; the traffic cost of this extra hop is negligible.&lt;/p&gt;




&lt;h1&gt;
  
  
  Architecture
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Container&lt;/td&gt;
&lt;td&gt;One image containing both Node 22 and uv/Python; Next.js directly calls the original Python scripts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Song Data &amp;amp; Audio&lt;/td&gt;
&lt;td&gt;Private Cloud Storage bucket, &lt;strong&gt;mounted as the &lt;code&gt;/data&lt;/code&gt; folder&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API key&lt;/td&gt;
&lt;td&gt;Secret Manager, provided as environment variables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access Control&lt;/td&gt;
&lt;td&gt;IAP + In-app verification of the IAP signature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instances&lt;/td&gt;
&lt;td&gt;Max 1, min 0; CPU always allocated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reasons for several decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mount the bucket as a folder&lt;/strong&gt; instead of rewriting the code to call the GCS API. The program originally read/wrote to the &lt;code&gt;output/&lt;/code&gt; folder; after mounting, I just need to point &lt;code&gt;SONG_DATA_DIR&lt;/code&gt; to &lt;code&gt;/data&lt;/code&gt;, requiring almost no code changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run only 1 instance&lt;/strong&gt;. To avoid duplicate audio generation, pausing calls when the quota is exhausted, and tracking the progress of adding new songs, these states are stored in memory. Also, the mounted bucket doesn't have cross-instance locking. For personal use, 1 is enough, and this ensures the mechanisms to prevent duplicate billing from the previous post remain effective.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU always allocated&lt;/strong&gt; (&lt;code&gt;--no-cpu-throttling&lt;/code&gt;). "Adding a new song" continues transcription and analysis in the background after the response is sent. Cloud Run defaults to throttling the CPU after a response is sent, which would stall background tasks.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Four Layers of Protection
&lt;/h1&gt;

&lt;p&gt;The final access control consists of four layers. If any single layer fails, the others still hold:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Run Permissions&lt;/strong&gt;: &lt;code&gt;--no-allow-unauthenticated&lt;/code&gt;, only the IAP service account can call this service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAP&lt;/strong&gt;: Only accounts granted &lt;code&gt;roles/iap.httpsResourceAccessor&lt;/code&gt; can pass, which is only me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-app Verification&lt;/strong&gt;: Every request verifies the IAP assertion header and compares it against the email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private Bucket&lt;/strong&gt;: Public access is strictly prohibited; only the service account dedicated to this service can read/write.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third layer might seem redundant since IAP is already in front. But it protects against "IAP layer misconfiguration": someone accidentally adding &lt;code&gt;--allow-unauthenticated&lt;/code&gt;, IAP being turned off, or ingress settings being changed. If that happens, the third layer is the last line of defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying IAP Signatures in Next.js 16
&lt;/h2&gt;

&lt;p&gt;Next.js 16 renamed &lt;code&gt;middleware&lt;/code&gt; to &lt;code&gt;proxy.ts&lt;/code&gt;, which runs in the Node.js runtime by default, making it perfect for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;K_SERVICE&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkIapAssertion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-goog-iap-jwt-assertion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IAP_AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;allowedEmails&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOWED_EMAILS&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="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[auth] rejected &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&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;Two design highlights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;K_SERVICE&lt;/code&gt; to determine if running on Cloud Run&lt;/strong&gt;. This environment variable is automatically set by Cloud Run. It doesn't exist during local development, so local work is unaffected, and there's no need for an extra "disable verification" switch (which would eventually be accidentally pushed to production).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reject all if configuration is missing&lt;/strong&gt;. If &lt;code&gt;IAP_AUDIENCE&lt;/code&gt; or &lt;code&gt;ALLOWED_EMAILS&lt;/code&gt; is not set, all requests return 500. A configuration error results in "the site won't open" rather than "the site is open to the public."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The verification itself follows &lt;a href="https://cloud.google.com/iap/docs/signed-headers-howto" rel="noopener noreferrer"&gt;IAP documentation&lt;/a&gt;: ES256 signature, issuer is &lt;code&gt;https://cloud.google.com/iap&lt;/code&gt;, audience is &lt;code&gt;/projects/PROJECT_NUMBER/locations/REGION/services/SERVICE_ID&lt;/code&gt;, and the public key is fetched from Google's JWK endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Test Before Going Live
&lt;/h2&gt;

&lt;p&gt;Since there's no real IAP to hit locally, I made the key source a replaceable parameter and tested 12 scenarios locally using my own generated ES256 keys:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Allowed account (including case sensitivity)&lt;/td&gt;
&lt;td&gt;Pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other accounts, no email&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong audience, wrong issuer, forged signature, expired, gibberish, missing header&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing audience, empty allowlist&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then I ran the production build in three modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local (no &lt;code&gt;K_SERVICE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;All 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On Cloud Run, but forgot settings&lt;/td&gt;
&lt;td&gt;All 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On Cloud Run, settings complete, but missing or forged header&lt;/td&gt;
&lt;td&gt;All 401&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h1&gt;
  
  
  Pitfall 1: Another Claude window already did half the work
&lt;/h1&gt;

&lt;p&gt;Halfway through coding, two files I didn't create appeared in &lt;code&gt;git status&lt;/code&gt;: &lt;code&gt;Dockerfile&lt;/code&gt; and &lt;code&gt;.dockerignore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It turns out I accidentally had two Claude Code windows open. Both were discussing song-lingo, and the other one had also talked about deployment and already written a Dockerfile using a different approach: &lt;strong&gt;mounting GCS as a folder&lt;/strong&gt;, while I was currently writing a whole storage abstraction layer to change all reads/writes to GCS API calls.&lt;/p&gt;

&lt;p&gt;After comparing, the mounting approach was clearly better: it required almost no code changes, and the only advantage of my abstraction (version checking on write) wasn't really needed for a single-instance, single-user scenario. So I deleted my abstraction and switched to mounting.&lt;/p&gt;

&lt;p&gt;But that wasn't all. After deploying, I found the service was already on &lt;strong&gt;revision 2&lt;/strong&gt;. Revision 1 had been created earlier that day, and &lt;strong&gt;IAP was already enabled&lt;/strong&gt;. That window hadn't just written a Dockerfile; it had actually deployed once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Two agents in the same repo and same GCP project were working independently, unaware of each other. Nothing went wrong this time because I checked &lt;code&gt;git status&lt;/code&gt; before acting and checked the revision list and service settings after deploying, rather than assuming "I am the first." My habit going forward: &lt;strong&gt;Only do one task in one window&lt;/strong&gt;, and always check what's already in the cloud before starting a deployment.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 2: Personal Gmail projects, IAP returns 502 for every request
&lt;/h1&gt;

&lt;p&gt;After deploying and enabling IAP, I opened the URL and got a 502.&lt;/p&gt;

&lt;p&gt;Initially, I thought the program crashed, but a key clue was in the response headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;
&lt;span class="na"&gt;x-goog-iap-generated-response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;

Empty Google Account OAuth client ID(s)/secret(s).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;x-goog-iap-generated-response: true&lt;/code&gt; means &lt;strong&gt;this error was returned by IAP itself&lt;/strong&gt;; the request never reached my code. The message indicates IAP has no OAuth client to use.&lt;/p&gt;

&lt;p&gt;The reason is that my project &lt;strong&gt;doesn't belong to any organization&lt;/strong&gt;; it was created with a personal Gmail account. IAP defaults to using a Google-managed OAuth client, which only supports accounts within an organization. For such projects, you must create your own OAuth client:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Google Auth Platform&lt;/strong&gt; (OAuth consent screen): Set User Type to External.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials → Create OAuth client ID → Web application&lt;/strong&gt;, set the redirect URI to &lt;code&gt;https://iap.googleapis.com/v1/oauth/clientIds/CLIENT_ID:handleRedirect&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;gcloud iap settings set&lt;/code&gt; to apply the client ID and secret to this service.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I executed step 3 in my own terminal rather than in the Claude Code chat, so the client secret wouldn't appear in any chat history.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the consent screen is already public?
&lt;/h2&gt;

&lt;p&gt;My project contains many other services, and the OAuth consent screen was already set to public for other apps. My first reaction was, "If it's public, can anyone log in? Should I switch back to testing mode?"&lt;/p&gt;

&lt;p&gt;The answer is &lt;strong&gt;no need to change, and you shouldn't&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;Layer&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;th&gt;Impact of being Public&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OAuth Consent Screen&lt;/td&gt;
&lt;td&gt;Confirm "Which Google account are you"&lt;/td&gt;
&lt;td&gt;Any account can complete the login step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAP Access Permissions&lt;/td&gt;
&lt;td&gt;Confirm "Can this account use this service"&lt;/td&gt;
&lt;td&gt;Unaffected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-app Verification&lt;/td&gt;
&lt;td&gt;Re-confirm signature and email&lt;/td&gt;
&lt;td&gt;Unaffected&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The consent screen is only responsible for "identifying who you are." The actual decision of "whether you can enter" is made by the subsequent two layers. Switching back to testing mode would instead affect other services sharing the same consent screen: only test users could log in, and authorization would expire in about 7 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: When you see a 502, check the response headers first. &lt;code&gt;x-goog-iap-generated-response&lt;/code&gt; tells you directly if the problem is with IAP or your code. Personal Gmail projects need their own OAuth client; whether the consent screen is public does not affect who can use the service.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pitfall 3: Ensuring &lt;code&gt;.env&lt;/code&gt; and lyrics aren't uploaded
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;gcloud run deploy --source .&lt;/code&gt; uploads the entire folder to Cloud Build. The local &lt;code&gt;.env&lt;/code&gt; has API keys, and &lt;code&gt;output/&lt;/code&gt; has all the lyrics; these must absolutely not be uploaded.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gcloud&lt;/code&gt; defaults to using &lt;code&gt;.gitignore&lt;/code&gt; if &lt;code&gt;.gcloudignore&lt;/code&gt; is missing, and both of these were already in &lt;code&gt;.gitignore&lt;/code&gt;. But "should be excluded" wasn't enough, so I explicitly wrote a &lt;code&gt;.gcloudignore&lt;/code&gt; and used gcloud's own command to list what would actually be uploaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud meta list-files-for-upload &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was 48 files; &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;output/&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt;, and &lt;code&gt;.next&lt;/code&gt; were all 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: For commands that send files out, use the tool's own listing feature to see "what is actually being sent" rather than relying on your own inference of ignore rules.&lt;/p&gt;




&lt;h1&gt;
  
  
  Small things that lead to misjudgment
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;zsh wildcards&lt;/strong&gt;: Counting objects with &lt;code&gt;gcloud storage ls -r gs://bucket/**&lt;/code&gt; resulted in 0. zsh tries to expand &lt;code&gt;**&lt;/code&gt; as a local wildcard first; if it finds nothing, it errors out, and the query is never sent. Adding quotes fixed it: 116 objects, perfectly matching local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fields not found by &lt;code&gt;--format&lt;/code&gt; don't error&lt;/strong&gt;: Querying bucket settings with &lt;code&gt;--format='value(iamConfiguration.publicAccessPrevention)'&lt;/code&gt; gave empty output. The field name changed in newer gcloud versions, but it doesn't error; it just silently gives you a blank. Switching to JSON output revealed &lt;code&gt;public_access_prevention: enforced&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The commonality here: &lt;strong&gt;"0" and "blank" do not mean "none" or "no problem."&lt;/strong&gt; When verifying security settings, if you get an empty result, suspect the query itself first.&lt;/p&gt;




&lt;h1&gt;
  
  
  Verification: Every layer must be tested
&lt;/h1&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%2Fjxf4mq0u1cnub1fze47t.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%2Fjxf4mq0u1cnub1fze47t.png" alt=" " width="800" height="1734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The post-deployment verification checklist, every item was actually run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Open homepage, API, audio without logging in&lt;/td&gt;
&lt;td&gt;IAP returns 302, redirects to Google login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attach a forged IAP signature header&lt;/td&gt;
&lt;td&gt;Still 302, IAP doesn't accept external signatures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a new song via POST without logging in&lt;/td&gt;
&lt;td&gt;IAP returns 401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anonymous access to bucket files and listing&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Run invocation permissions&lt;/td&gt;
&lt;td&gt;Only IAP service account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bucket public permissions&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;allUsers&lt;/code&gt; or &lt;code&gt;allAuthenticatedUsers&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;My account logs in and plays&lt;/td&gt;
&lt;td&gt;Normal, in-app verification 0 rejections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other Google account logs in&lt;/td&gt;
&lt;td&gt;"You don’t have access"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two can only be tested in a browser. Confirming "my account can use it" is actually the most critical: the audience format was filled according to documentation; if it were wrong, I would pass IAP but be blocked by my own code. I confirmed this by searching Cloud Run logs for &lt;code&gt;[auth] rejected&lt;/code&gt;, which returned 0.&lt;/p&gt;

&lt;p&gt;I also saw something in the logs that made me very happy: I &lt;strong&gt;added a new song directly in the cloud&lt;/strong&gt;, and the entire workflow—transcription, analysis, demo audio generation, and playing from the bucket—worked perfectly within the container.&lt;/p&gt;




&lt;h1&gt;
  
  
  Costs and To-dos
&lt;/h1&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Estimate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Run&lt;/td&gt;
&lt;td&gt;Scales to 0 when not in use, no charge; CPU always allocated only charges during instance uptime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Storage&lt;/td&gt;
&lt;td&gt;~27MB, less than US$0.01 per month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Build&lt;/td&gt;
&lt;td&gt;A few minutes per deployment, within free tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini API&lt;/td&gt;
&lt;td&gt;Same as local, billed by usage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There are two more things I need to do in the Console:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restrict API key usage&lt;/strong&gt; to only call the Generative Language API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set budget alerts&lt;/strong&gt;. If all the previous protections fail, this is the final insurance to cap losses and ensure I'm notified.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also note: the bucket and local &lt;code&gt;output/&lt;/code&gt; are now two independent sets of data. Songs added in the cloud won't automatically sync back; use &lt;code&gt;gcloud storage rsync&lt;/code&gt; when needed.&lt;/p&gt;




&lt;h1&gt;
  
  
  Key Takeaways
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;"Only I can access" is a chain, not a door.&lt;/strong&gt; No matter how well IAP is set up, a single signed URL can bypass it. When checking access control, list all paths through which data can exit, not just the entrance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a setting is missing, the system should fail closed, not open.&lt;/strong&gt; In-app verification rejects everything if settings are missing. If a future deployment misses an environment variable, the result will be that I can't open the site and will notice immediately, rather than the site silently being open to the public for weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look at who returned the error.&lt;/strong&gt; For a 502, the &lt;code&gt;x-goog-iap-generated-response&lt;/code&gt; header narrows the problem down from "the whole service" to "IAP OAuth configuration."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suspect the query first for empty results.&lt;/strong&gt; An object count of 0 or a blank configuration field might be a malformed query rather than the resource not existing. In security verification, the cost of such a misjudgment is particularly high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do one task in only one agent window.&lt;/strong&gt; Two Claude Code windows acting on the same project simultaneously were only prevented from overwriting each other because I checked the current state before acting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep secrets out of the conversation.&lt;/strong&gt; OAuth client secrets and API keys were handled in my own terminal. The AI was only responsible for giving me the commands, ensuring secrets never entered the chat history.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/kkdai/song-lingo" rel="noopener noreferrer"&gt;kkdai/song-lingo&lt;/a&gt;. The deployment section of the README has complete commands and a verification checklist (values replaced with placeholders). Related documentation: &lt;a href="https://cloud.google.com/run/docs/securing/identity-aware-proxy-cloud-run" rel="noopener noreferrer"&gt;IAP for Cloud Run&lt;/a&gt;, &lt;a href="https://cloud.google.com/iap/docs/signed-headers-howto" rel="noopener noreferrer"&gt;Verifying IAP assertion headers&lt;/a&gt;, &lt;a href="https://cloud.google.com/iap/docs/custom-oauth-configuration" rel="noopener noreferrer"&gt;IAP custom OAuth configuration&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cloud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>[AI in Practice] Building a Dynamic LINE Group Buying Bot with Edit and Unsend Webhooks</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Thu, 03 Sep 2026 04:27:12 +0000</pubDate>
      <link>https://dev.to/gde/ai-in-practice-building-a-dynamic-line-group-buying-bot-with-edit-and-unsend-webhooks-1clh</link>
      <guid>https://dev.to/gde/ai-in-practice-building-a-dynamic-line-group-buying-bot-with-edit-and-unsend-webhooks-1clh</guid>
      <description>&lt;h1&gt;
  
  
  When Messages Can Be Taken Back: Building a "Shapeshifting" LINE Group Buying Bot with Edit and Unsend Webhooks
&lt;/h1&gt;

&lt;p&gt;Author: Evan Lin, LINE Taiwan Developer Relations Team Lead&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%2Fvgxwfi0rvtwqw9baqhcq.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%2Fvgxwfi0rvtwqw9baqhcq.png" alt="img" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On August 20, 2026, LINE announced the open free trial of the "Edit Message" feature in LINE Labs.&lt;/p&gt;

&lt;p&gt;For general users, this is a very intuitive feature: if you find a typo, a wrong date, or want to adjust your tone after sending a message, you no longer need to go through the "Unsend, retype, resend" process. You can simply edit the original text message.&lt;/p&gt;

&lt;p&gt;But when I saw this feature, the first thing I thought of was something else:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a user changes a message that has already been processed by a LINE Bot, does the Bot know?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer is: Yes. The LINE Messaging API provides the &lt;code&gt;Edit event&lt;/code&gt;; and when a user unsends a message, there is also an &lt;code&gt;Unsend event&lt;/code&gt; that can be received.&lt;/p&gt;

&lt;p&gt;In this article, I want to share how I turned these two types of webhook events into a LINE Bot Demo with a story, as well as several easily overlooked but very important details during implementation.&lt;/p&gt;

&lt;p&gt;The complete sample code is available on GitHub:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kkdai/linebot-edit-unsend" rel="noopener noreferrer"&gt;https://github.com/kkdai/linebot-edit-unsend&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting with "Edit Message" in LINE Labs
&lt;/h2&gt;

&lt;p&gt;To try out message editing in LINE Labs currently, you need to meet the following conditions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Update the mobile version of LINE to version 26.12.0 or higher.&lt;/li&gt;
&lt;li&gt;Go to "Home → Settings → LINE Labs".&lt;/li&gt;
&lt;li&gt;Enable "Edit Messages".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In standard one-on-one and group chats, text messages can be edited within 15 minutes of being sent; for Keep Notes, it's within 6 days. Photos, videos, voice messages, files, and stickers cannot be edited at this time. After modification, the chat room will display "Edited," but it does not provide a way to view or restore old versions.&lt;/p&gt;

&lt;p&gt;There is also a limitation directly related to Bot development: currently, one-on-one chats with LINE Official Accounts do not support message editing. Therefore, to test the Messaging API's Edit event, the Bot must be added to a group chat.&lt;/p&gt;

&lt;p&gt;For features and activation methods, please refer to the &lt;a href="https://www.linecorp.com/tw/pr/news/2026/0820/" rel="noopener noreferrer"&gt;LINE Taiwan Newsroom Announcement&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  From UI Features to Bot Data Consistency
&lt;/h2&gt;

&lt;p&gt;In the past, the common process for a Bot receiving a text message was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receive message webhook.&lt;/li&gt;
&lt;li&gt;Parse the text.&lt;/li&gt;
&lt;li&gt;Write to the database or trigger subsequent processes.&lt;/li&gt;
&lt;li&gt;Reply with the processing result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a message cannot be modified after being sent, this model is very simple. But when messages can be edited, the original text is not necessarily the user's final intent.&lt;/p&gt;

&lt;p&gt;For example, a user sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pearl Milk Tea / Half Sugar / Less Ice / 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Bot has already recorded it as one drink costing 60 TWD. A few seconds later, the user directly edits the original message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pearl Milk Tea / Micro Sugar / No Ice / 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the Bot does not handle the Edit event, the chat room sees two cups, but the backend still records one cup. The world on the screen and the world inside the Bot become disconnected.&lt;/p&gt;

&lt;p&gt;This is the most practical value of the Edit event: it's not just a notification that "the text has changed," but an opportunity for the service to synchronize with the user's latest intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group Buying Transformation: Giving the API Demo a Story
&lt;/h2&gt;

&lt;p&gt;To demonstrate both edit and unsend simultaneously, I set the Bot as "Shapeshifting Store Manager - Dan Dan," responsible for saving the office from low energy at 3:00 PM.&lt;/p&gt;

&lt;p&gt;The Demo flow is as follows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The group leader enters &lt;code&gt;Start Group Buy 50 Lan 15:20 Deadline&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Alice enters &lt;code&gt;Pearl Milk Tea / Half Sugar / Less Ice / 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Alice edits the same message to &lt;code&gt;Pearl Milk Tea / Micro Sugar / No Ice / 2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The Bot receives the Edit event and updates the quantity, sweetness, ice level, and total amount.&lt;/li&gt;
&lt;li&gt;Bob unsends his message after placing an order.&lt;/li&gt;
&lt;li&gt;The Bot receives the Unsend event, deletes Bob's order, and recalculates.&lt;/li&gt;
&lt;li&gt;Enter &lt;code&gt;Current Orders&lt;/code&gt; to view the latest summary via Flex Message.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This scenario is perfect for a Demo because everyone knows the two most common things in group buying: changing your mind after ordering, and suddenly not wanting to drink after ordering.&lt;/p&gt;

&lt;p&gt;More importantly, the audience can directly see from the quantity and amount whether the webhook actually affected the backend state, rather than the Bot just replying "Edit received."&lt;/p&gt;

&lt;h2&gt;
  
  
  Structure of the Edit Event
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; of the Edit event is &lt;code&gt;messageEdited&lt;/code&gt;, which carries the edited text, timestamp, reply token, and message ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"messageEdited"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"replyToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"950e63e8f46542ab89f645b4c2a1180a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"610830548529053697"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Pearl Milk Tea / Micro Sugar / No Ice / 2"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1776914799524&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"group"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"groupId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ca56f94637c..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"U4af4980629..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most critical design point is: the &lt;code&gt;message.id&lt;/code&gt; in the edit event is the same as the message ID of the original message event.&lt;/p&gt;

&lt;p&gt;Therefore, we can directly use the message ID as the order ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageEvent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageEditedEvent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handleEdit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UnsendEvent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handleUnsend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add an order when the original message event is received; when &lt;code&gt;MessageEditedEvent&lt;/code&gt; is received, use the same message ID to find the order and overwrite the content.&lt;/p&gt;

&lt;p&gt;Additionally, the Edit event has its own reply token, which is different from the original message event's reply token, so the Bot can directly reply "Order successfully transformed" to this specific edit.&lt;/p&gt;

&lt;p&gt;If your Bot also uses the Mark as Read API, note that the Edit event does not carry a &lt;code&gt;markAsReadToken&lt;/code&gt;. You cannot simply apply the standard message event processing flow. These field differences are well-suited for a full test using webhook fixtures after upgrading the SDK.&lt;/p&gt;

&lt;p&gt;For complete fields, please refer to &lt;a href="https://developers.line.biz/en/reference/messaging-api/#edit-event" rel="noopener noreferrer"&gt;Messaging API: Edit event&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Trap: Edit Events May Not Arrive in Order
&lt;/h2&gt;

&lt;p&gt;This is the most easily overlooked part of this implementation.&lt;/p&gt;

&lt;p&gt;A user might edit the same message multiple times in quick succession, and multiple &lt;code&gt;messageEdited&lt;/code&gt; webhooks are not guaranteed to arrive in the order they were edited. Official documentation recommends using the largest timestamp to represent the latest state.&lt;/p&gt;

&lt;p&gt;Therefore, you cannot simply adopt the "last received webhook"; you should adopt the "webhook with the latest timestamp":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdatedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buy&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;errStaleEdit&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;
&lt;span class="n"&gt;buy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;messageID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Assuming the second edit arrives first and the first edit arrives later, this logic prevents old content from overwriting new content.&lt;/p&gt;

&lt;p&gt;At the same time, the LINE Platform might resend webhooks, so the example also uses &lt;code&gt;webhookEventId&lt;/code&gt; for deduplication. These two mechanisms handle different problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;webhookEventId&lt;/code&gt;: Prevents the same event from being processed twice.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timestamp&lt;/code&gt;: Prevents different edit events from updating data in the wrong order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both need to be handled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Trap: Edited Content May No Longer Be a Valid Order
&lt;/h2&gt;

&lt;p&gt;Users don't necessarily just change the quantity. They might also change the original order to something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I suddenly don't want to drink anymore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this case, the new message can no longer pass the order format validation. If the backend continues to keep the old order, it will still cause state inconsistency.&lt;/p&gt;

&lt;p&gt;The approach in this Demo is: clear the old order content, mark this record as invalid and temporarily remove it from the total, and then ask the user to continue editing to fix it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;parseOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sugar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UnitPrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;
    &lt;span class="n"&gt;buy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;messageID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buy&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actual products can adopt different strategies, such as putting it into a "waiting for manual confirmation" state; the key is not to silently continue using old content that no longer exists in the chat room.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unsend Event: Beyond Technology, User Intent
&lt;/h2&gt;

&lt;p&gt;The content of the Unsend event is simpler than the Edit event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unsend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"group"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"groupId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ca56f94637c..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"U4af4980629..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unsend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"messageId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"610830548529053697"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only tells us which message ID was unsent; it does not re-attach the message content, nor does it have a reply token.&lt;/p&gt;

&lt;p&gt;Since we already use the message ID as the order ID, deletion is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messageID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But what's truly important here is not &lt;code&gt;delete()&lt;/code&gt;, but how to respect the user's intent of "I want to take back this content."&lt;/p&gt;

&lt;p&gt;Official documentation specifically reminds service providers that after receiving an Unsend event, they should handle it carefully so that the target message cannot be seen or used in the future. Therefore, this Demo will not quote the unsent item, sweetness, or other original text in the Bot's reply, but will only say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;💨 An order has been safely withdrawn, and the saved content has been deleted.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since the Unsend event has no reply token, if you want to proactively notify the group, you can only use a push message. This means it will count towards message usage, so quota and cost should be considered when designing a formal service.&lt;/p&gt;

&lt;p&gt;For full details, please refer to &lt;a href="https://developers.line.biz/en/reference/messaging-api/#unsend-event" rel="noopener noreferrer"&gt;Messaging API: Unsend event&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making State Changes Visible with Flex Message
&lt;/h2&gt;

&lt;p&gt;While plain text can show results, to make the Demo understandable at a glance, I highly recommend using Flex Message to display the current order.&lt;/p&gt;

&lt;p&gt;The card this time includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store name and deadline&lt;/li&gt;
&lt;li&gt;Group buy status (In progress or Closed)&lt;/li&gt;
&lt;li&gt;Each member's item, sweetness, ice level, quantity, and subtotal&lt;/li&gt;
&lt;li&gt;Total quantity and total amount&lt;/li&gt;
&lt;li&gt;"Refresh Order" button&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After every addition, edit, or unsend, the Flex Message is regenerated from the current state. This way, the quantity and amount before and after an edit change immediately, and the line item disappears after an unsend.&lt;/p&gt;

&lt;p&gt;To avoid the Flex Message becoming too large, the example displays a maximum of eight orders, with the rest summarized. Formal products can switch to carousels, LIFF pages, or add pagination queries as needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  SDK and Execution Environment Versions
&lt;/h2&gt;

&lt;p&gt;This example uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;github.com/line/line-bot-sdk-go/v8 v8.22.0
Go 1.25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older versions of the SDK may already have &lt;code&gt;UnsendEvent&lt;/code&gt;, but they might not include the new &lt;code&gt;MessageEditedEvent&lt;/code&gt; type. After upgrading the SDK, you also need to check the required Go version; when upgrading from the old example this time, the Go toolchain and CI workflow both needed adjustment.&lt;/p&gt;

&lt;p&gt;This is a common but easily missed upgrade issue: being able to compile locally doesn't mean Cloud Build or GitHub Actions are still using the same version.&lt;/p&gt;

&lt;h2&gt;
  
  
  State Issues When Deploying to Cloud Run
&lt;/h2&gt;

&lt;p&gt;This Demo is deployed on Google Cloud Run. The channel secret and channel access token are injected via Secret Manager and are not committed to the repository.&lt;/p&gt;

&lt;p&gt;To keep the example simple, current orders are stored in the program's memory. This brings two limitations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If the instance restarts or scales to zero, orders will disappear.&lt;/li&gt;
&lt;li&gt;When multiple instances exist simultaneously, each instance will have a different order state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Therefore, the Demo environment sets the maximum instances to 1 to avoid webhooks for the same group buy being split. But this is only suitable for demonstration, not a complete solution for a production environment.&lt;/p&gt;

&lt;p&gt;Formal services should use Redis, Firestore, or other shared storage, and further handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Atomic updates and concurrency control&lt;/li&gt;
&lt;li&gt;Webhook idempotency&lt;/li&gt;
&lt;li&gt;Data retention periods&lt;/li&gt;
&lt;li&gt;Cross-system deletion after unsend&lt;/li&gt;
&lt;li&gt;Push Message failure retries&lt;/li&gt;
&lt;li&gt;The boundary between audit logs and privacy requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Especially for unsend, if message content has already been sent to search indices, analysis platforms, or other downstream services, deleting only the main database is incomplete. Data flow design should know where content went from the start to be able to truly complete a deletion.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Run This Demo
&lt;/h2&gt;

&lt;p&gt;First, please enable webhooks in the LINE Developers Console and allow the Bot to join group chats. Point the webhook URL to the &lt;code&gt;/callback&lt;/code&gt; of your deployed service.&lt;/p&gt;

&lt;p&gt;Then, enter the following in the group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Start Group Buy 50 Lan 15:20 Deadline
Pearl Milk Tea / Half Sugar / Less Ice / 1
Current Orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then directly edit the second message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pearl Milk Tea / Micro Sugar / No Ice / 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will see the Bot reply "Order successfully transformed," and the quantity and total amount in the Flex Message will also update.&lt;/p&gt;

&lt;p&gt;Finally, unsend that order message, and the Bot will delete the content and show a new order summary.&lt;/p&gt;

&lt;p&gt;The menu and prices in the example are fixed Demo data to make amount changes clearly visible. For complete startup instructions, environment variables, and test commands, please refer to the README in the repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kkdai/linebot-edit-unsend" rel="noopener noreferrer"&gt;https://github.com/kkdai/linebot-edit-unsend&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Else Can This Be Applied To?
&lt;/h2&gt;

&lt;p&gt;Group buying is just one easy-to-understand story. The same event model can be extended to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Itinerary Bots: Update the itinerary when the meeting time is edited, and cancel the activity when the message is unsent.&lt;/li&gt;
&lt;li&gt;Task Bots: Edit the assignee or deadline, and remove the task when the message is unsent.&lt;/li&gt;
&lt;li&gt;Reservation Bots: Modify the time slot or number of people, and cancel the reservation when the message is unsent.&lt;/li&gt;
&lt;li&gt;Announcement Bots: Update announcement content, and synchronize the removal of copies on other channels after unsend.&lt;/li&gt;
&lt;li&gt;Interactive Story Bots: Rewrite the plot when a choice is edited, and return to the previous node when a message is unsent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As long as a Bot has ever converted "a message" into some kind of system state, it's worth re-checking: when the message is edited or unsent, should that state also change?&lt;/p&gt;

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

&lt;p&gt;Message editing looks like an improvement to the chat interface, but from a Bot developer's perspective, it actually changes the lifecycle of an event.&lt;/p&gt;

&lt;p&gt;A message is no longer just the moment it is "sent." It might be updated, or it might be withdrawn. Backend services need to understand these events, maintain the correct order, and respect the user's latest intent.&lt;/p&gt;

&lt;p&gt;This time, I used an afternoon tea group buy story to string together Edit event, Unsend event, Flex Message, and Cloud Run. I hope this small example helps everyone master the new features faster and start thinking about how their own LINE Bots should respond after a message is changed.&lt;/p&gt;

&lt;p&gt;Feel free to refer to the code, and I look forward to seeing everyone create more interesting applications:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kkdai/linebot-edit-unsend" rel="noopener noreferrer"&gt;https://github.com/kkdai/linebot-edit-unsend&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.linecorp.com/tw/pr/news/2026/0820/" rel="noopener noreferrer"&gt;LINE Premium "Edit Message" feature open for free trial in LINE Labs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/reference/messaging-api/#edit-event" rel="noopener noreferrer"&gt;LINE Messaging API reference: Edit event&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/reference/messaging-api/#unsend-event" rel="noopener noreferrer"&gt;LINE Messaging API reference: Unsend event&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://techblog.lycorp.co.jp/zh-hant/linebot-mark-as-read" rel="noopener noreferrer"&gt;Introduction to New LINE Messaging API Features: Mark as Read API lets your chatbot mark messages as read&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>development</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>[AI in Practice] Gemini 3.5 Transcribe: Real-time Transcription and Speaker Diarization in a macOS Meeting Translation App</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Fri, 28 Aug 2026 15:53:34 +0000</pubDate>
      <link>https://dev.to/gde/ai-in-practice-gemini-35-transcribe-real-time-transcription-and-speaker-diarization-in-a-macos-152h</link>
      <guid>https://dev.to/gde/ai-in-practice-gemini-35-transcribe-real-time-transcription-and-speaker-diarization-in-a-macos-152h</guid>
      <description>&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%2F4wnfrw357vsw583xqswm.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%2F4wnfrw357vsw583xqswm.png" alt="Gemini 3.5 Transcribe Announcement Image" width="799" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Previously
&lt;/h1&gt;

&lt;p&gt;I have a macOS App I use myself, &lt;a href="https://github.com/kkdai/gemini-live-translate-macos" rel="noopener noreferrer"&gt;gemini-live-translate-macos&lt;/a&gt;. It uses ScreenCaptureKit to directly capture audio from a specified App, eliminating the need for virtual sound cards like BlackHole. It then sends the audio to the Gemini Live API for real-time translation, outputting Traditional Chinese subtitles while playing Chinese audio. I've written two posts about the development process: &lt;a href="https://dev.to/agy-macos-app/"&gt;the first one&lt;/a&gt; was about building it from scratch using AGY CLI, and &lt;a href="https://dev.to/agy-macos-app-enhance/"&gt;the second one&lt;/a&gt; was about using Claude Code to take it from "functional" to "user-friendly."&lt;/p&gt;

&lt;p&gt;The starting point for this new addition was simple: I saw a document for "Real-time Transcription" added to the Live API. Since I was already connected to the Live API, I thought adding a pure transcription mode would just be a matter of changing a few parameters.&lt;/p&gt;

&lt;p&gt;However, after checking the documentation, I realized that Google released &lt;strong&gt;two models with very similar names but very different capabilities&lt;/strong&gt; at once. The specific feature I actually wanted (speaker diarization) wasn't available at all on the model I originally thought it was.&lt;/p&gt;




&lt;h1&gt;
  
  
  Two Models with Names Differing by Only Two Words
&lt;/h1&gt;

&lt;p&gt;Let's lay out the differences first; this is the part I spent the most time figuring out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;gemini-3.5-transcribe-live&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;gemini-3.5-transcribe&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API Used&lt;/td&gt;
&lt;td&gt;Live API (WebSocket streaming)&lt;/td&gt;
&lt;td&gt;Interactions API (Standard HTTP request)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage Scenario&lt;/td&gt;
&lt;td&gt;Transcribe while speaking&lt;/td&gt;
&lt;td&gt;Upload the whole file after recording&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speaker Diarization&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;td&gt;Up to 8 speakers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Word-level Timestamps&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio Length&lt;/td&gt;
&lt;td&gt;10 minutes per session&lt;/td&gt;
&lt;td&gt;1 hour (30 mins with diarization)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smart Mode&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SMART&lt;/code&gt; available&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;smart&lt;/code&gt; is mutually exclusive with diarization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interim Subtitles&lt;/td&gt;
&lt;td&gt;Has &lt;code&gt;interimInputTranscription&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The official documentation on the Live page's limitations section is very blunt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Speaker diarization is not supported in live streaming sessions. For speaker diarization, use the non-streaming Audio transcription endpoint.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So, "seeing who is saying what in real-time" is currently impossible. For speaker diarization, you must record it and send the whole thing after the meeting. This limitation determined my entire subsequent architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real-time One: Interim is the Key
&lt;/h2&gt;

&lt;p&gt;The setup shape is different from the original translation model. &lt;code&gt;responseModalities&lt;/code&gt; must be &lt;code&gt;TEXT&lt;/code&gt;, and transcription parameters are placed under &lt;code&gt;setup.inputAudioTranscription&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"setup"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"models/gemini-3.5-transcribe-live"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"generationConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"responseModalities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TEXT"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"inputAudioTranscription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"languageCodes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SMART"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"realtimeInputConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"automaticActivityDetection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"disabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leaving &lt;code&gt;languageCodes&lt;/code&gt; empty enables automatic language detection; filling it with BCP-47 codes like &lt;code&gt;["en-US"]&lt;/code&gt; gives it a language preference. &lt;code&gt;mode&lt;/code&gt; has two values: &lt;code&gt;VERBATIM&lt;/code&gt; keeps everything word-for-word, while &lt;code&gt;SMART&lt;/code&gt; removes filler words like "uh" and "um" and automatically adds formatting. For meeting minutes, I chose &lt;code&gt;SMART&lt;/code&gt;, which is much cleaner to read.&lt;/p&gt;

&lt;p&gt;There is also a &lt;code&gt;customVocabulary&lt;/code&gt; where you can stuff technical terms, with a limit of 1000 items, though the documentation suggests staying under 100 for best results. I didn't implement this yet; I'll wait until I encounter names that are constantly misheard.&lt;/p&gt;

&lt;p&gt;The response side adds a field that the original translation model didn't have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;interimInputTranscription&lt;/code&gt;: Tentative results while speaking, which will be overwritten by subsequent content.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inputTranscription&lt;/code&gt;: The finalized text when the speaker pauses or the turn ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This distinction affects how the UI is written, which I'll discuss later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Batch One: Uses a Completely Different API
&lt;/h2&gt;

&lt;p&gt;This is the easiest place to trip up. &lt;code&gt;gemini-3.5-transcribe&lt;/code&gt; doesn't use &lt;code&gt;generateContent&lt;/code&gt;; it uses the Interactions API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;https://generativelanguage.googleapis.com/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;beta/interactions&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3.5-transcribe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"audio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR_FILE_URI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mime_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"audio/wav"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"generation_config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"transcription_config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"language_codes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verbatim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"diarization_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"speaker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"timestamp_granularities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"word"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;diarization_mode: "speaker"&lt;/code&gt; is the switch for speaker diarization, but it can only be paired with &lt;code&gt;verbatim&lt;/code&gt;. In other words: &lt;strong&gt;if you want speaker diarization, you have to give up the benefits of SMART mode's filler word removal&lt;/strong&gt;; you can't have both.&lt;/p&gt;

&lt;p&gt;I didn't include &lt;code&gt;timestamp_granularities&lt;/code&gt; at first because it wasn't in the short example in the documentation. Without it, the entire feature silently fails—a process I'll describe in the final section.&lt;/p&gt;

&lt;p&gt;Audio must first be uploaded to the Files API to get a file URI, which is then included in the request. The official documentation doesn't provide a base64 embedding example. The response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model_output"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello world"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"word_info"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"speaker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"spk_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"start_offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.100s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end_offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.450s"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Speaker labels are in the &lt;strong&gt;word-level&lt;/strong&gt; annotations as IDs like &lt;code&gt;spk_1&lt;/code&gt;, &lt;code&gt;spk_2&lt;/code&gt;. The model doesn't know who is who. The documentation also notes a maximum of 8 speakers, and "attribution for 3 or more people is experimental."&lt;/p&gt;




&lt;h1&gt;
  
  
  Phase 1: Adding Pure Transcription Mode to the App
&lt;/h1&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%2Fe24t62ord3olsb09fj9u.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%2Fe24t62ord3olsb09fj9u.png" alt="MeetingTranslator using gemini-3.5-transcribe-live to transcribe a Chinese podcast, automatically popping up AI-organized meeting minutes after stopping" width="800" height="732"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This part was simpler than I expected because the App was already parsing &lt;code&gt;inputTranscription&lt;/code&gt; and &lt;code&gt;outputTranscription&lt;/code&gt; (the translation model returns both for bilingual subtitles). The real change was in the mode branching.&lt;/p&gt;

&lt;p&gt;The original connection layer determined it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;isTranslateModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modelName&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"live-translate"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A boolean split into two paths. Now needing three, I switched to an enum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;LiveMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;translate&lt;/span&gt; &lt;span class="c1"&gt;// gemini-*-live-translate-*: output translated audio + bilingual subtitles&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;transcribe&lt;/span&gt; &lt;span class="c1"&gt;// gemini-*-transcribe-live: text only&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;general&lt;/span&gt; &lt;span class="c1"&gt;// other Live models: rely on systemInstruction for interpretation&lt;/span&gt;

    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;modelName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;LiveMode&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;modelName&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"transcribe"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transcribe&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;modelName&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"live-translate"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;translate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;general&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;I also took the opportunity to extract the setup config generation and server response parsing from &lt;code&gt;GeminiLiveConnection&lt;/code&gt; into pure functions. That file was getting a bit bloated; extracting these reduced it by over sixty lines, and the extracted parts can be tested directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Preventive Trade-off: Duplicate Words
&lt;/h2&gt;

&lt;p&gt;When parsing responses, I made a decision worth noting. Besides &lt;code&gt;inputTranscription&lt;/code&gt;, the &lt;code&gt;parts&lt;/code&gt; in &lt;code&gt;modelTurn&lt;/code&gt; might also carry the same text. If both are collected, the same sentence will appear twice in the subtitles and export file.&lt;/p&gt;

&lt;p&gt;To be honest, I &lt;strong&gt;haven't actually seen this happen&lt;/strong&gt;, but while reading the documentation and old code, I noticed both paths would lead to &lt;code&gt;didReceiveOutputTranscription&lt;/code&gt;. I blocked it to avoid the risk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Content for pure transcription mode is already provided by inputTranscription.&lt;/span&gt;
&lt;span class="c1"&gt;// Receiving text from modelTurn again would cause the same sentence to appear twice.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputTranscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The downside of this preventive defense is that if it never actually happens, this line is dead code that no one understands. So I wrote it as a test ("transcribe mode ignores modelTurn text"), ensuring the behavior is locked in and the intent is documented.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Run Tests in a Project Without a Testing Framework
&lt;/h2&gt;

&lt;p&gt;This project doesn't use an Xcode project file; it uses a &lt;code&gt;build_app.sh&lt;/code&gt; that calls &lt;code&gt;swiftc&lt;/code&gt; to compile all &lt;code&gt;.swift&lt;/code&gt; files into a &lt;code&gt;.app&lt;/code&gt;. No SwiftPM means no &lt;code&gt;swift test&lt;/code&gt; and no XCTest.&lt;/p&gt;

&lt;p&gt;My solution was to use the same trick again: since the extracted parts are pure functions, I wrote a &lt;code&gt;main.swift&lt;/code&gt; as an assertion runner. I compile it with those pure functions into an executable and use the exit code to determine success or failure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;swiftc &lt;span class="nt"&gt;-sdk&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SDK_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-target&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARCH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-apple-macos13.0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BUILD_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/run_tests"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  LiveSetupConfig.swift TranscriptFormatter.swift &lt;span class="se"&gt;\&lt;/span&gt;
  WAVRecorder.swift AudioChunker.swift &lt;span class="se"&gt;\&lt;/span&gt;
  GeminiTranscribeService.swift GeminiSummaryService.swift &lt;span class="se"&gt;\&lt;/span&gt;
  Tests/main.swift
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assertion function itself is less than ten lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="n"&gt;checkEqual&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;T&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Equatable&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;T&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&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;actual&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;passedCount&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; (Actual: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;describing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;, Expected: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This obviously can't compete with a real testing framework—no setup/teardown, no parallel execution, and it won't tell you which line failed. But it runs, it prevents regressions, and it doesn't require converting the whole project to SwiftPM just for testing. This phase ended with 37 assertions; after adding speaker diarization, it grew to 98.&lt;/p&gt;




&lt;h1&gt;
  
  
  Phase 2: Speaker Diarization Requires More Changes Than Expected
&lt;/h1&gt;

&lt;p&gt;"Adding speaker diarization" sounds like calling one more API, but it actually involves three more tasks: saving audio to a file, uploading it, and then connecting to a completely different API. And because batch transcription might take several minutes, it can't block the original stop process.&lt;/p&gt;

&lt;p&gt;The final data flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    A[ScreenCaptureKit captures PCM] --&amp;gt; B[Gemini Live API&amp;lt;br/&amp;gt;Real-time Subtitles]
    A --&amp;gt; C[WAVRecorder&amp;lt;br/&amp;gt;Synchronous writing]

    D[Press Stop] --&amp;gt; E[Phase 1: Immediate Output]
    E --&amp;gt; E1[Transcript md]
    E --&amp;gt; E2[AI Meeting Summary]
    E --&amp;gt; E3[Meeting Record Webpage]

    D --&amp;gt; F[Phase 2: Background Execution]
    F --&amp;gt; F1[Collect file → Split if &amp;gt; 30 mins]
    F1 --&amp;gt; F2[Upload to Files API]
    F2 --&amp;gt; F3[Interactions API Batch Transcription]
    F3 --&amp;gt; F4[Delete cloud copy]
    F4 --&amp;gt; F5[Write diarized.md]
    F5 --&amp;gt; F6[Rerun summary with speaker info + infer names]
    F6 --&amp;gt; F7[Regenerate webpage]
    F7 --&amp;gt; F8[Delete local recording]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Splitting it into two phases was intentional. Every step in Phase 2 could fail (upload timeout, quota exhausted, response format mismatch). If it were hooked into the middle of the original process, a failure would mean losing even the basic transcript and summary. Now, Phase 1 remains untouched, and Phase 2 is just appended; if it fails, you just lose one output.&lt;/p&gt;

&lt;p&gt;The recording format was also conveniently available: the audio sent to the Live API is already resampled to 16kHz mono 16-bit PCM. Writing that same data to disk results in valid WAV content—just add a 44-byte header, no re-encoding required.&lt;/p&gt;

&lt;p&gt;The same defensive mindset was applied to parsing: &lt;code&gt;parseDiarized&lt;/code&gt; assumes the response structure based on documentation, but if the actual format differs, I left a fallback—if word annotations aren't found, it falls back to the entire &lt;code&gt;output_text&lt;/code&gt; so the transcript isn't completely empty. This fallback actually came in handy, though not in the way I expected, as I'll explain in the last section.&lt;/p&gt;




&lt;h1&gt;
  
  
  Major Pitfalls and Solutions
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Chunking cannot be "fill 30 minutes and leave a remainder"
&lt;/h2&gt;

&lt;p&gt;The audio limit for speaker diarization is 30 minutes, but meetings often last over an hour, so chunking is necessary.&lt;/p&gt;

&lt;p&gt;My first idea was the most intuitive: fill 30 minutes for one segment and leave the rest as the last segment. While writing tests, I realized this approach had two holes.&lt;/p&gt;

&lt;p&gt;A 60-minute and 5-second meeting would be split into 30 mins, 30 mins, and 5 seconds. Sending that 5-second tail for transcription is meaningless and costs an extra upload and API call. What if I merge the tail into the previous segment? Then that segment becomes 30 mins and 5 seconds, &lt;strong&gt;exceeding the API limit&lt;/strong&gt;, and the whole segment gets rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Switch to even splitting. Calculate how many segments are needed (round up), then distribute the total length evenly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usable&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;maxChunkBytes&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="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;maxChunkBytes&lt;/span&gt;
&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;usable&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;ranges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Range&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;usable&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;blockAlign&lt;/span&gt; &lt;span class="c1"&gt;// Align to 16-bit sample boundary&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;usable&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;ranges&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;..&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;60 mins and 5 seconds becomes two segments of 30 mins and 2.5 seconds? No, that would exceed the limit. Actually, it's &lt;code&gt;ceil(3605 / 1800) = 3&lt;/code&gt;, split into three segments of 20 minutes each. There will never be a tiny remainder segment, and it will never exceed the limit.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;end -= end % blockAlign&lt;/code&gt; is also necessary. 16-bit mono uses 2 bytes per sample. If you cut on an odd byte, the entire subsequent audio stream's bytes will be shifted by one, resulting in noise when played.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: spk_1 in Segment 2 is not the same as spk_1 in Segment 1
&lt;/h2&gt;

&lt;p&gt;A problem I thought of only after chunking. Each segment is an independent API call. The model doesn't know what happened in the previous segment, so &lt;code&gt;spk_1&lt;/code&gt; in Segment 2 has no relation to &lt;code&gt;spk_1&lt;/code&gt; in Segment 1; they could be different people.&lt;/p&gt;

&lt;p&gt;If you just concatenate the three segments, the reader will naturally assume &lt;code&gt;spk_1&lt;/code&gt; is the same person throughout. This is worse than having no speaker labels: &lt;strong&gt;it gives a false sense of certainty&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Whenever chunking occurs, append the segment number to the ID to create a namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;qualifiedLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;chunkIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;chunkCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;chunkCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;speaker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"Seg&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;chunkIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Insert an explanatory line at the segment boundaries during export:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;---
&lt;/span&gt;&lt;span class="gt"&gt;
&amp;gt; Segment 2 (Speaker IDs are not continuous with the previous segment)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These IDs are also used for the text sent to the AI for name inference. By sharing the same vocabulary, the model has a chance to link the same person across segments—if someone is called "Evan" in both Segment 1 and Segment 3, it can map them individually rather than being forced to assume the IDs are identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: Joining words results in "H e l l o W o r l d"
&lt;/h2&gt;

&lt;p&gt;The batch API returns &lt;strong&gt;word-level&lt;/strong&gt; annotations, which you have to join into sentences yourself. For English, it's intuitive: join with spaces.&lt;/p&gt;

&lt;p&gt;The problem is Chinese. Gemini's Chinese tokens joined with spaces look like "Ni hao shi jie wo men jin tian" (Hello world we today), looking like a word segmentation exercise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: When joining characters, check the properties of the characters on both sides. If either side is CJK (Chinese, Japanese, Korean), don't add a space; also, don't add a space before punctuation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;needsSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="nv"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Character&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="nv"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Character&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isCJK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isCJK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPunctuation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isCJK&lt;/code&gt; checks Unicode blocks, covering CJK Unified Ideographs, Kana, Hangul, and full-width characters.&lt;/p&gt;

&lt;p&gt;The punctuation rule was added later. Originally I only blocked CJK, but testing &lt;code&gt;["Hello", ",", "world"]&lt;/code&gt; revealed it became &lt;code&gt;Hello , world&lt;/code&gt;. You'd never notice this without writing tests because it doesn't "break"—it's just a bit ugly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 4: Two asynchronous processes racing to write the same summary
&lt;/h2&gt;

&lt;p&gt;Phase 1 generates an AI meeting summary after stopping. Phase 2 generates another one after getting the diarized transcript (this time with speaker info, so the "Assignee" field in action items can be filled).&lt;/p&gt;

&lt;p&gt;Normally, Phase 2 is definitely slower—it has to upload over 100MB of audio and wait for transcription. But "normally slower" isn't a guarantee. If the Phase 1 summary API happens to hang and retry, while the Phase 2 audio is only one minute long and finishes quickly, the order will reverse. The late-returning old summary from Phase 1 would overwrite the diarized result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Phase 2 waits for Phase 1 to finish before writing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wait for Phase 1 summary to land, otherwise it might return after us and overwrite the diarized result&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;minutesTask&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isCancelled&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A one-line fix, but you have to first realize that "these two things actually have no guaranteed order." This kind of race almost never appears in testing; it only happens on a day with particularly bad network, leaving the user with a confusingly reverted meeting record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 5: During the TDD RED phase, the test itself crashed
&lt;/h2&gt;

&lt;p&gt;While writing tests for the WAV header, I followed TDD rules: write the test first, create a stub returning an empty &lt;code&gt;Data()&lt;/code&gt;, and run it to see it fail. Instead of failing, the entire test program crashed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Swift/arm64e-apple-macos.swiftinterface:41299: Fatal error:
UnsafeRawBufferPointer.load out of bounds
Trace/BPT trap: 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My test was trying to read the 24th byte to check if the sample rate was 16000, but the stub returned empty &lt;code&gt;Data&lt;/code&gt;, causing an out-of-bounds read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Zero-pad before reading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;produced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;WAVRecorder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dataByteCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;checkEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;produced&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"WAV header is 44 bytes"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Pad with zeros if length is insufficient, so subsequent fields report failure instead of crashing the test&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;produced&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;repeating&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="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;produced&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a small thing, but it highlighted something I usually ignore: &lt;strong&gt;the test program itself must be resilient to the object under test being completely broken.&lt;/strong&gt; If a test ends in a crash during the RED phase instead of reporting a failure, you only know "something broke," not which of the twelve fields were wrong. After padding with zeros, a single run lists all twelve expected values, allowing me to implement them by following a list rather than guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 6: Scroll anchor fails in the new mode
&lt;/h2&gt;

&lt;p&gt;There's a structural difference between real-time transcription and translation modes: translation mode accumulates words into the "current sentence" and pushes to history only when punctuation is reached; transcription mode's &lt;code&gt;inputTranscription&lt;/code&gt; is a finalized sentence that goes straight to history.&lt;/p&gt;

&lt;p&gt;The original auto-scroll was written like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"currentLine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ID &lt;code&gt;currentLine&lt;/code&gt; was attached to the display block for the "current sentence." In transcription mode, the sentence goes to history as soon as it's finalized, and that block immediately disappears—the scroll target no longer exists, so the screen stays put.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Use a bottom anchor that always exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;height&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="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bottomAnchor"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This bug had no error message or crash; the "new feature just wouldn't scroll," and it only became apparent when there was enough content to exceed the screen. I found it by reading the view's conditional branches, not by running it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 7: Letting AI guess names—the key is stopping it from hallucinating
&lt;/h2&gt;

&lt;p&gt;Once you have &lt;code&gt;spk_1&lt;/code&gt; and &lt;code&gt;spk_2&lt;/code&gt;, the natural desire is to replace the IDs with real names. This is actually feasible—meetings often contain clues like "Evan, how's the progress on your end?" or "I'm Sarah, in charge of frontend." If you give the tagged transcript to the model, it can make the connection.&lt;/p&gt;

&lt;p&gt;But this is also where hallucinations are most likely. Models are happy to "infer" a name from tone, job content, or speaking frequency and present it with the same confidence as a fact in the meeting minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause &amp;amp; Solution&lt;/strong&gt;: Three methods combined.&lt;/p&gt;

&lt;p&gt;First, the schema explicitly allows null and requires evidence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"speakers"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ARRAY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"items"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OBJECT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"properties"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"label"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"STRING"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Must&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;clues&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;are&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;found&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rather&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;than&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;forcing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"STRING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nullable"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"evidence"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"STRING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nullable"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"required"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the prompt sets strict rules:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Only fill in the name if there is a clear address, roll call, or self-introduction in the transcript or meeting notes. Explain in 'evidence' which sentence led to this conclusion. Do not speculate on names based on tone, job content, or speaking frequency. If no clear clues are found, both name and evidence must return null.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Third, &lt;strong&gt;remove the confidence score&lt;/strong&gt;. I originally designed a &lt;code&gt;confidence&lt;/code&gt; field but later removed it. The reason is that a model's self-assessment of confidence is inherently unreliable, and &lt;code&gt;evidence&lt;/code&gt; already fully serves this role: if there's evidence, the inference succeeded; if not, it didn't. An extra confidence score just makes people think it's more credible than it is—"It says 0.7 here, so it's probably 70% accurate"—but that 0.7 doesn't come from any real probability distribution.&lt;/p&gt;

&lt;p&gt;The output looks like this, with evidence for successful inferences and honest admissions for failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Participants&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Evan**&lt;/span&gt; (spk_1) — Evidence: Addressed as "Evan, how's the progress on your end?" in Segment 3
&lt;span class="p"&gt;-&lt;/span&gt; spk_2 — Insufficient clues in transcript and notes to identify name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the ID in parentheses is also intentional. Seeing &lt;code&gt;**Evan (spk_1)**&lt;/code&gt; tells you it was inferred; if it's wrong, you can verify it yourself. If it just said &lt;code&gt;**Evan**&lt;/code&gt;, it would look like an absolute fact.&lt;/p&gt;

&lt;p&gt;Another source of clues: I also send the meeting notes the user wrote on the spot. Notes often already contain a list of participants, which significantly increases the success rate. But a safeguard is needed here—the prompt must clearly state "Notes can only be used to map speaker names; do not treat note content as something someone said in the summary or action items," otherwise your own memos might turn into someone else's speech.&lt;/p&gt;




&lt;h1&gt;
  
  
  Privacy and Cleanup: Don't leave behind what you don't want to keep
&lt;/h1&gt;

&lt;p&gt;Batch transcription inherently creates two more pieces of data than real-time streaming: the local recording file and the copy uploaded to Google. Both must have a clear disposal plan.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recording files are only created if the option is checked.&lt;/strong&gt; If speaker diarization isn't enabled, no file is written, and nothing stays on the disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local recordings are deleted upon successful transcription.&lt;/strong&gt; A one-hour meeting is about 115MB, which adds up quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files are kept if transcription fails.&lt;/strong&gt; This is an intentional exception: if it fails, the recording is kept, and the status bar tells you the path so you can retry or handle it yourself. Deleting it then would be actual data loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud copies are deleted immediately after use.&lt;/strong&gt; Files API files expire automatically after 48 hours, but "it will disappear eventually" and "I'm sure it's gone now" are two different things.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Delete the cloud copy immediately after use, don't wait 48 hours for auto-expiration&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;GeminiFilesUploader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;uploaded&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Results and Benefits
&lt;/h1&gt;

&lt;p&gt;The numbers for both phases combined:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Pure Transcription Mode&lt;/th&gt;
&lt;th&gt;Speaker Diarization&lt;/th&gt;
&lt;th&gt;Post-testing Fixes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New Files&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total Assertions&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;td&gt;108&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commit&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/kkdai/gemini-live-translate-macos/commit/b0e12c5" rel="noopener noreferrer"&gt;&lt;code&gt;b0e12c5&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/kkdai/gemini-live-translate-macos/commit/486ba8f" rel="noopener noreferrer"&gt;&lt;code&gt;486ba8f&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/kkdai/gemini-live-translate-macos/commit/686899a" rel="noopener noreferrer"&gt;&lt;code&gt;686899a&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://github.com/kkdai/gemini-live-translate-macos/commit/1cc41c3" rel="noopener noreferrer"&gt;&lt;code&gt;1cc41c3&lt;/code&gt;&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;GeminiLiveConnection.swift&lt;/code&gt;, the file that originally did everything, was reduced by over sixty lines after setup generation and response parsing were extracted. Those two pure function modules are now guarded by over 20 tests. This was an unexpected benefit: &lt;strong&gt;the decoupling done to make things testable was itself a refactoring I had put off for a long time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, confirm which model has the feature you want.&lt;/strong&gt; I initially assumed "Real-time Transcription" would have speaker diarization—they're both transcription models, right? The only difference should be real-time vs. batch. I only found out otherwise after checking the docs, and the difference isn't just a parameter switch; it's architectural: for diarization, you must record, upload, use a different API, accept a 30-minute limit, and give up SMART mode. If I hadn't checked first, I would have hit a whole new subsystem while expecting to just "change a few parameters."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations often dictate the architecture.&lt;/strong&gt; Almost every design decision this time was forced by limitations: the 30-minute limit forced chunking, chunking forced ID namespaces, the slowness of batching forced two-phase export, and the mutual exclusivity of &lt;code&gt;smart&lt;/code&gt; and &lt;code&gt;diarization&lt;/code&gt; forced me to choose between a clean transcript and speaker labels. Checking limitations before designing is much easier than designing and then hitting a wall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real-time transcription half has been tested in the field.&lt;/strong&gt; The screenshot above is the result of feeding it a Japanese video; &lt;code&gt;gemini-3.5-transcribe-live&lt;/code&gt; automatically detected Japanese and output a Japanese transcript directly without translation, and the meeting record webpage was generated as usual after stopping. Leaving &lt;code&gt;languageCodes&lt;/code&gt; empty for auto-detection actually works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The speaker diarization half had issues during field testing&lt;/strong&gt;, and in a way I didn't expect: the file was generated, the transcript was correct, and the program reported no errors, but there was no speaker separation. This part deserves its own section.&lt;/p&gt;




&lt;h1&gt;
  
  
  Postscript: Documentation examples might give you an empty result without errors
&lt;/h1&gt;

&lt;p&gt;After posting, I ran speaker diarization on a real conversation. &lt;code&gt;meeting-2026-08-28-11-54-diarized.md&lt;/code&gt; was generated, the content was complete, not a word was missing, but there were no speaker labels from beginning to end—just one continuous block of text.&lt;/p&gt;

&lt;p&gt;No error messages, the status bar showed success, and the file had everything it should. This kind of failure is the hardest to debug because it looks like success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a Minimal Reproducible Example First
&lt;/h2&gt;

&lt;p&gt;The first problem was that I had no evidence—the App didn't save the raw response, and the recording of that meeting was automatically deleted because "transcription succeeded." To re-run it, I'd have to start another meeting with no guarantee of reproduction.&lt;/p&gt;

&lt;p&gt;So instead of guessing what broke, I first found a way to get a raw response. macOS's built-in &lt;code&gt;say&lt;/code&gt; command can use different voices, so I used it to synthesize a two-person conversation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;say &lt;span class="nt"&gt;-v&lt;/span&gt; Alex &lt;span class="nt"&gt;-o&lt;/span&gt; a1.aiff &lt;span class="s2"&gt;"Hi Samantha, did you finish the quarterly report yesterday?"&lt;/span&gt;
say &lt;span class="nt"&gt;-v&lt;/span&gt; Samantha &lt;span class="nt"&gt;-o&lt;/span&gt; a2.aiff &lt;span class="s2"&gt;"Yes Alex, I sent it to the whole team this morning."&lt;/span&gt;
say &lt;span class="nt"&gt;-v&lt;/span&gt; Alex &lt;span class="nt"&gt;-o&lt;/span&gt; a3.aiff &lt;span class="s2"&gt;"Sure, I will look at the budget section this afternoon."&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;a1 a2 a3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;afconvert &lt;span class="nt"&gt;-f&lt;/span&gt; WAVE &lt;span class="nt"&gt;-d&lt;/span&gt; LEI16@16000 &lt;span class="nt"&gt;-c&lt;/span&gt; 1 &lt;span class="nv"&gt;$f&lt;/span&gt;.aiff &lt;span class="nv"&gt;$f&lt;/span&gt;.wav&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three segments joined together are 17 seconds long with two speakers, in the exact same format as the App's recordings (16kHz mono 16-bit). Then I used curl to go through the entire upload and transcription process, dumping the full JSON.&lt;/p&gt;

&lt;p&gt;This step took less than five minutes, but it turned "starting another meeting, running for ten minutes, and not being sure of reproduction" into "changing one field, running for ten seconds, and seeing the difference immediately." &lt;strong&gt;A minimal reproducible example is worth the time&lt;/strong&gt;, especially when the original reproduction path is expensive.&lt;/p&gt;

&lt;p&gt;The dumped response looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hi Samantha, did you finish..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model_output"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;811 bytes, complete transcript, &lt;strong&gt;zero annotations&lt;/strong&gt;. So the problem wasn't my parsing; it was the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root Cause 1: Word-level annotations must be explicitly enabled
&lt;/h2&gt;

&lt;p&gt;Using the same audio and the same uploaded file, I changed just one field in the request:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request Content&lt;/th&gt;
&lt;th&gt;Objects with &lt;code&gt;speaker&lt;/code&gt; in response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Only &lt;code&gt;diarization_mode: "speaker"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Removed &lt;code&gt;language_codes: []&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Added &lt;code&gt;timestamp_granularities: ["word"]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Speaker IDs are attached to &lt;code&gt;word_info&lt;/code&gt; annotations, and word-level annotations must be explicitly requested in the request to be returned. No request, no annotations; no annotations, no speakers.&lt;/p&gt;

&lt;p&gt;I didn't include this field originally because I copied the shortest Python example from the documentation—that example only had &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;diarization_mode&lt;/code&gt;. The full REST example elsewhere in the documentation actually has &lt;code&gt;timestamp_granularities&lt;/code&gt;, but by then I already "knew" how to write it and didn't look back.&lt;/p&gt;

&lt;p&gt;The most frustrating part is that &lt;strong&gt;it doesn't report an error&lt;/strong&gt;. The API returns 200, gives you the full transcript, and the &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;completed&lt;/code&gt;. If it had returned an error like "You requested speaker diarization but didn't enable word annotations," I would have fixed it in five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root Cause 2: The actual label format differs from the documentation
&lt;/h2&gt;

&lt;p&gt;After adding the field, annotations appeared, but they looked different from what I expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Hi,"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"start_offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"0.100s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"end_offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"0.500s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"speaker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"spk:0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"word_info"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;spk:0&lt;/code&gt;. &lt;strong&gt;A colon, and starting from 0.&lt;/strong&gt; The documentation consistently uses &lt;code&gt;spk_1&lt;/code&gt;, &lt;code&gt;spk_2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My display logic was written like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"spk_"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;upperBound&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"Speaker &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;speaker&lt;/span&gt; &lt;span class="c1"&gt;// ← Fallback prints the raw ID if no match&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;spk:0&lt;/code&gt; doesn't match &lt;code&gt;spk_&lt;/code&gt;, so it fell straight into the fallback, displaying &lt;code&gt;**spk:0**:&lt;/code&gt; on the screen.&lt;/p&gt;

&lt;p&gt;My tests completely missed this bug for a simple reason: &lt;strong&gt;the test data was written according to the documentation.&lt;/strong&gt; The documentation was wrong, so the tests were wrong, and the green light told me everything was fine. This is the main thing I want to record: for external API tests, you are actually testing "my understanding of this API," not the API itself. If your understanding is wrong, the test will faithfully protect that error.&lt;/p&gt;

&lt;p&gt;I didn't fix it by changing &lt;code&gt;spk_&lt;/code&gt; to &lt;code&gt;spk:&lt;/code&gt;; that would just be betting in a different direction. Instead, I stopped parsing the number in the ID and used the order in which the speaker first appeared in that segment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// The actual format of the ID is determined by the API (actual returns spk:0, spk:1, while official docs say spk_1),&lt;/span&gt;
&lt;span class="c1"&gt;/// so we don't parse the number in the ID. Instead, we number them based on their first appearance in this segment.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;speakerOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;DiarizedSegment&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[:]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;segment&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&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;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It won't break even if the format changes again because it doesn't look at the format at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  My own fallback made failure look like success
&lt;/h2&gt;

&lt;p&gt;I wrote this earlier and was quite proud of it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;parseDiarized&lt;/code&gt; assumes the response structure based on documentation, but if the actual format differs, I left a fallback—if word annotations aren't found, it falls back to the entire &lt;code&gt;output_text&lt;/code&gt; so the transcript isn't completely empty.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This fallback did work, and the effect was as expected: the user got a complete transcript with nothing lost.&lt;/p&gt;

&lt;p&gt;But it also &lt;strong&gt;hid the failure&lt;/strong&gt;. If I hadn't had that fallback, &lt;code&gt;-diarized.md&lt;/code&gt; would have been empty or not generated at all, and I would have known immediately that something was wrong. With it, I got a file that looked perfectly normal, just missing the feature I wanted—the very feature that was the sole reason for enabling it.&lt;/p&gt;

&lt;p&gt;I haven't fully figured out the balance here. Graceful degradation isn't wrong; the mistake is &lt;strong&gt;not speaking up after degrading&lt;/strong&gt;. My current approach is to keep the fallback but change the status bar message when it's triggered, explicitly stating "Speaker info not obtained this time" instead of the usual "Diarized transcript saved."&lt;/p&gt;

&lt;h2&gt;
  
  
  After the Fix
&lt;/h2&gt;

&lt;p&gt;Running the full parsing with a real response, both segments spoken by Alex correctly returned to Speaker 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**Speaker 1**&lt;/span&gt;: Hi, Samantha. Did you finish the quarterly report yesterday?
&lt;span class="gs"&gt;**Speaker 2**&lt;/span&gt;: Yes, Alex. I sent it to the whole team this morning. Could you review the budget section?
&lt;span class="gs"&gt;**Speaker 1**&lt;/span&gt;: Sure. I will look at the budget section this afternoon and get back to you.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quality of the speaker diarization itself is good. Tests increased from 98 to 106, with the extra eight being regression tests for these two root causes—this time, the test data wasn't copied from the docs but clipped from real responses.&lt;/p&gt;

&lt;p&gt;Three more details learned from field testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The REST endpoint only accepts snake_case; sending &lt;code&gt;generationConfig&lt;/code&gt; is explicitly rejected with &lt;code&gt;Unknown parameter 'generationConfig'. Did you mean 'generation_config'?&lt;/code&gt;. This is a great error message, a hundred times more useful than the silent failure above.&lt;/li&gt;
&lt;li&gt;Annotations include &lt;code&gt;start_index&lt;/code&gt; and &lt;code&gt;end_index&lt;/code&gt;, mapping directly to positions in &lt;code&gt;content.text&lt;/code&gt;. Using these to split strings is much more accurate than my heuristic for joining CJK/English words, meaning the entire &lt;code&gt;joinWords&lt;/code&gt; could be removed. I haven't done this yet.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;state&lt;/code&gt; of uploaded files is immediately &lt;code&gt;ACTIVE&lt;/code&gt;; 17 seconds of audio didn't go through a &lt;code&gt;PROCESSING&lt;/code&gt; phase. My polling logic seems redundant for short audio, but I don't know if it's needed for long audio, so I'll keep it for now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;This article thus has two endings, and I've decided to keep both.&lt;/strong&gt; The first part said "Speaker diarization hasn't been verified yet; I'll update after I run it," and then I actually ran it, and it broke. If I had edited the first part and only kept the fixed version, this would have been a smooth "I did X, and it worked" post—but what actually happened was "I did X according to the docs, it failed silently, and it took me half an hour to find out why." The latter is much more useful to readers.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/kkdai/gemini-live-translate-macos" rel="noopener noreferrer"&gt;kkdai/gemini-live-translate-macos&lt;/a&gt;. The two official documents are &lt;a href="https://ai.google.dev/gemini-api/docs/live-api/live-transcribe" rel="noopener noreferrer"&gt;Live transcription&lt;/a&gt; and &lt;a href="https://ai.google.dev/gemini-api/docs/transcribe" rel="noopener noreferrer"&gt;Audio transcription&lt;/a&gt;. I recommend reading the limitations section on the speaker diarization page thoroughly before starting.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>gemini</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>[Go in Practice] Writing Modern Go with AI: Testing JetBrains go-modern-guidelines and Refactoring a 1,039-line main.go</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Thu, 27 Aug 2026 04:13:56 +0000</pubDate>
      <link>https://dev.to/gde/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines-and-refactoring-151o</link>
      <guid>https://dev.to/gde/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines-and-refactoring-151o</guid>
      <description>&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%2Fx30kespxnt8ydxb5lb5q.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%2Fx30kespxnt8ydxb5lb5q.png" alt="JetBrains go-modern-guidelines：Help AI coding agents write modern Go" width="800" height="568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Background
&lt;/h1&gt;

&lt;p&gt;In the AI era, even I delegate most of my code optimization or writing tasks to AI. However, due to factors in model training data, too many writing styles are outdated. This results in code that cannot utilize features of the latest Go versions, which is quite a pity.&lt;/p&gt;

&lt;p&gt;Fortunately, JetBrains released &lt;code&gt;go-modern-guidelines&lt;/code&gt;, a very useful plugin. It makes your AI Agent smarter and teaches it how to use the latest syntax to optimize your Golang code.&lt;/p&gt;




&lt;h1&gt;
  
  
  What is go-modern-guidelines?
&lt;/h1&gt;

&lt;h2&gt;
  
  
  The problem it aims to solve: Models have a knowledge cutoff, Go doesn't
&lt;/h2&gt;

&lt;p&gt;The positioning of this project is very straightforward: &lt;strong&gt;Provide contemporary Go writing specifications for AI agents so they don't write outdated Go due to knowledge cutoffs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The problem has two layers. The first layer is easy to understand: training data has a cutoff. Anything added to the standard library after that cutoff won't be used because the model hasn't seen it. The project's own example is &lt;code&gt;errors.AsType[T]&lt;/code&gt; (Go 1.26); if the model hasn't seen it, it naturally won't write it.&lt;/p&gt;

&lt;p&gt;The second layer is more subtle, which the project calls &lt;strong&gt;frequency bias&lt;/strong&gt;: even if the model "knows" the new way, the old way appears overwhelmingly more often in the training data. In ten years of Go code on the internet, &lt;code&gt;interface{}&lt;/code&gt; appears far more than &lt;code&gt;any&lt;/code&gt;, and &lt;code&gt;sort.Slice&lt;/code&gt; far more than &lt;code&gt;slices.SortFunc&lt;/code&gt;. Models perform probabilistic prediction; the one that wins by majority vote is usually the old one.&lt;/p&gt;

&lt;p&gt;I really saw this second point in this refactoring. The original project had this snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The oauth2 library can return an error containing "invalid_grant"&lt;/span&gt;
&lt;span class="c"&gt;// when the refresh token is expired, revoked, or otherwise invalid.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;errorStr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c"&gt;// Basic substring check to avoid importing "strings"&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errorStr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;13&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errorStr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="m"&gt;13&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"invalid_grant"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hand-rolled string search, with a comment specifically explaining "to avoid importing strings". &lt;code&gt;strings&lt;/code&gt; is in the standard library; the cost of importing it is zero. What this code actually needed was just one line: &lt;code&gt;strings.Contains(err.Error(), "invalid_grant")&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works: Two commands, a list that grows with Go versions
&lt;/h2&gt;

&lt;p&gt;The tool itself is a CLI with only two subcommands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;list [--go-version &amp;lt;version&amp;gt; | --file-path &amp;lt;path&amp;gt;]
    Returns a list of guidelines supported by this Go version, sorted from newest to oldest.

explain &amp;lt;id&amp;gt;...
    Returns detailed explanations and before/after examples for specific guidelines.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point of &lt;code&gt;list&lt;/code&gt; is that &lt;strong&gt;it provides different answers based on the Go version&lt;/strong&gt;. You can pass a file path directly, and it will look up for &lt;code&gt;go.mod&lt;/code&gt;, &lt;code&gt;go.work&lt;/code&gt;, or fall back to the local Go toolchain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go-modern-guidelines list &lt;span class="nt"&gt;--file-path&lt;/span&gt; ~/Documents/linebot-file/main.go

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My project's &lt;code&gt;go.mod&lt;/code&gt; specifies &lt;code&gt;go 1.24.0&lt;/code&gt;, so it returned 45 guidelines. Change the version number, and the count changes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Go Version&lt;/th&gt;
&lt;th&gt;Guideline Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.21&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.22&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.24&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.25&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.26&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.27&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This design is intentional: &lt;strong&gt;it only suggests syntax that your project version can actually use.&lt;/strong&gt; This is crucial for AI agents; otherwise, it might happily suggest &lt;code&gt;errors.AsType[T]&lt;/code&gt;, and your CI would fail because it's running on Go 1.24.&lt;/p&gt;

&lt;p&gt;Looking at the differences between versions, it's essentially a condensed list of Go's recent features:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;list &lt;span class="nt"&gt;--go-version&lt;/span&gt; 1.21&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;list &lt;span class="nt"&gt;--go-version&lt;/span&gt; 1.22&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;range_over_int: Use &lt;span class="k"&gt;for &lt;/span&gt;i :&lt;span class="o"&gt;=&lt;/span&gt; range n when iterating from 0 to n-1.
&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;loopvar_capture: Do not add redundant loop-variable copies before closures or
&lt;span class="gp"&gt;  taking addresses;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Go 1.22 gives each iteration its own variables.
&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cmp_or: Use cmp.Or to pick the first non-zero value from a fallback chain.
&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;reflect_type_for: Use reflect.TypeFor[T]&lt;span class="o"&gt;()&lt;/span&gt; instead of reflect.TypeOf&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;T&lt;span class="o"&gt;)(&lt;/span&gt;nil&lt;span class="o"&gt;))&lt;/span&gt;.Elem&lt;span class="o"&gt;()&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;http_servemux_patterns: Use method-aware ServeMux patterns and r.PathValue &lt;span class="k"&gt;for&lt;/span&gt;
&lt;span class="go"&gt;  path parameters.

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;list&lt;/code&gt; provides a one-line summary; use &lt;code&gt;explain&lt;/code&gt; for detailed instructions when you're ready to work. Output looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;go-modern-guidelines explain cmp_or
&lt;span class="go"&gt;
cmp_or:
  Since: Go 1.22

  Summary:
    Use cmp.Or to pick the first non-zero value from a fallback chain.

  Details:
    cmp.Or returns the first non-zero value from its arguments. It is concise
    for simple fallback chains, but remember that all arguments are evaluated
    before the call.

  Examples:

  Before:
    name := os.Getenv("NAME")
    if name == "" {
      name = "default"
    }

  After:
    name := cmp.Or(os.Getenv("NAME"), "default")

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last sentence in the &lt;code&gt;Details&lt;/code&gt; section: "all arguments are evaluated before the call." This is the real trap of &lt;code&gt;cmp.Or&lt;/code&gt;—if your fallback source is an expensive function call, writing &lt;code&gt;cmp.Or(a(), b())&lt;/code&gt; will execute both. This kind of "you can use it, but know the cost" reminder is much more useful than just telling you to change the syntax.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-layer information design is actually to save context
&lt;/h2&gt;

&lt;p&gt;This list/explain layering might look like just interface design, but it's actually for the AI agent's context window. 45 guidelines, each with a one-line summary, take about 1000 tokens; but if every guideline included full explanations and before/after examples, just stuffing this list would burn tens of thousands of tokens.&lt;/p&gt;

&lt;p&gt;So the workflow is: first &lt;code&gt;list&lt;/code&gt; to scan everything, determine which guidelines are relevant to the current code, and then only call &lt;code&gt;explain&lt;/code&gt; for those. In this case, I actually only &lt;code&gt;explain&lt;/code&gt;ed six guidelines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dot"&gt;&lt;code&gt;&lt;span class="k"&gt;graph&lt;/span&gt; &lt;span class="nv"&gt;TD&lt;/span&gt;
    &lt;span class="nv"&gt;A&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Prepare&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;modify&lt;/span&gt; &lt;span class="nv"&gt;Go&lt;/span&gt; &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;list&lt;/span&gt; &lt;span class="err"&gt;--&lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;go&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;B&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;C&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Parse&lt;/span&gt; &lt;span class="nv"&gt;Go&lt;/span&gt; &lt;span class="nv"&gt;version&lt;/span&gt; &lt;span class="nv"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;go&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;mod&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;C&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;D&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Return&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt; &lt;span class="nv"&gt;guidelines&lt;/span&gt; &lt;span class="nv"&gt;available&lt;/span&gt; &lt;span class="nv"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;that&lt;/span&gt; &lt;span class="nv"&gt;version&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;br/&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;one&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="nv"&gt;line&lt;/span&gt; &lt;span class="nv"&gt;summary&lt;/span&gt; &lt;span class="nv"&gt;each&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;D&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;E&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;Which&lt;/span&gt; &lt;span class="nv"&gt;ones&lt;/span&gt; &lt;span class="nv"&gt;are&lt;/span&gt; &lt;span class="nv"&gt;relevant&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;this&lt;/span&gt; &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;E&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;|&lt;/span&gt;&lt;span class="nv"&gt;Pick&lt;/span&gt; &lt;span class="nv"&gt;candidates&lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;F&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;explain&lt;/span&gt; &lt;span class="nv"&gt;cmp_or&lt;/span&gt; &lt;span class="nv"&gt;min_max&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;F&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;G&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Get&lt;/span&gt; &lt;span class="nv"&gt;detailed&lt;/span&gt; &lt;span class="nv"&gt;explanations&lt;/span&gt; &lt;span class="nv"&gt;and&lt;/span&gt; &lt;span class="nv"&gt;before&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;after&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;G&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;H&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Actually&lt;/span&gt; &lt;span class="nv"&gt;apply&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;E&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;|&lt;/span&gt;&lt;span class="nv"&gt;None&lt;/span&gt; &lt;span class="nv"&gt;relevant&lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;I&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Write&lt;/span&gt; &lt;span class="nv"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;original&lt;/span&gt; &lt;span class="nv"&gt;way&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's one rule in the skill documentation written with particular emphasis: &lt;strong&gt;Do not pipe the output of &lt;code&gt;list&lt;/code&gt; to &lt;code&gt;head&lt;/code&gt;, &lt;code&gt;tail&lt;/code&gt;, or &lt;code&gt;grep&lt;/code&gt;&lt;/strong&gt;, as you might miss important guidelines. I violated this rule on my first try, which I'll discuss in the "Pitfalls" section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;For Claude Code, it's two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin marketplace add JetBrains/go-modern-guidelines
/plugin install modern-go-guidelines@goland-claude-marketplace

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once installed, it triggers automatically for Go-related tasks, or you can call it manually: &lt;code&gt;/modern-go-guidelines:use-modern-go&lt;/code&gt;. Cursor, Junie, and Codex have their own installation methods; other agents can use &lt;code&gt;npx skills add JetBrains/go-modern-guidelines&lt;/code&gt;. The project is licensed under Apache 2.0.&lt;/p&gt;

&lt;p&gt;On the first run, the wrapper script will automatically install the CLI to the local cache directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;go-modern-guidelines: installing github.com/JetBrains/go-modern-guidelines@v0.1.1
  into /Users/xxx/.cache/go-modern-guidelines/v0.1.1

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  This project: a main.go that grew to 1039 lines
&lt;/h1&gt;

&lt;p&gt;First, some background. The architecture of &lt;code&gt;linebot-file&lt;/code&gt; isn't complex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dot"&gt;&lt;code&gt;&lt;span class="k"&gt;graph&lt;/span&gt; &lt;span class="nv"&gt;LR&lt;/span&gt;
    &lt;span class="nv"&gt;A&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;LINE&lt;/span&gt; &lt;span class="nv"&gt;App&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;|&lt;/span&gt;&lt;span class="nv"&gt;Send&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;LINE&lt;/span&gt; &lt;span class="nv"&gt;Platform&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;B&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;|&lt;/span&gt;&lt;span class="nv"&gt;webhook&lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;C&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Cloud&lt;/span&gt; &lt;span class="nv"&gt;Run&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;C&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;|&lt;/span&gt;&lt;span class="nv"&gt;Read&lt;/span&gt; &lt;span class="nv"&gt;token&lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;D&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;Firestore&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;C&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;|&lt;/span&gt;&lt;span class="nv"&gt;Upload&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;Query&lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;E&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Google&lt;/span&gt; &lt;span class="nv"&gt;Drive&lt;/span&gt; &lt;span class="nv"&gt;API&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users authorize with &lt;code&gt;/connect_drive&lt;/code&gt;, tokens are stored in Firestore, and files sent to the chatroom are automatically uploaded to a folder structure like &lt;code&gt;LINE Bot Uploads/YYYY-MM/&lt;/code&gt;. Features were added incrementally, and everything was piled into &lt;code&gt;main.go&lt;/code&gt;, where the &lt;code&gt;main()&lt;/code&gt; function itself took up 564 lines.&lt;/p&gt;




&lt;h1&gt;
  
  
  What the health check found
&lt;/h1&gt;

&lt;p&gt;This section isn't directly related to &lt;code&gt;go-modern-guidelines&lt;/code&gt;—that tool manages whether the "writing style is contemporary," not whether the "logic is correct." But these are the things that actually bite users, so I'll record them anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Code that compiles but will never execute
&lt;/h2&gt;

&lt;p&gt;This is the most interesting one. The original event handling looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageEvent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TextMessageContent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FileMessageContent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FollowEvent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;// ← Note the indentation level here&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LinkRichMenuIdToUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;richMenuConnect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;webhook.FollowEvent&lt;/code&gt; was written inside the &lt;strong&gt;inner&lt;/strong&gt; switch. The inner switch evaluates &lt;code&gt;e.Message&lt;/code&gt;, which is of type &lt;code&gt;MessageContentInterface&lt;/code&gt;—a follow event can never be the content of a message.&lt;/p&gt;

&lt;p&gt;Why did it compile? Go does check type switches; if a case type cannot possibly implement that interface, the compiler reports &lt;code&gt;impossible type switch case&lt;/code&gt;. The problem lies in the SDK's interface definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;MessageContentInterface&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only requires a &lt;code&gt;GetType() string&lt;/code&gt;. And &lt;code&gt;FollowEvent&lt;/code&gt; happens to have this method (all event types do), so in the type system, it "can" be a &lt;code&gt;MessageContentInterface&lt;/code&gt;. The compiler allows it, but it never matches at runtime.&lt;/p&gt;

&lt;p&gt;Actual consequence: &lt;strong&gt;When new users add the bot as a friend, the Rich Menu for guiding authorization was never bound.&lt;/strong&gt; This feature had probably been broken for a long time because it doesn't report an error; it just quietly does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Group messages cause a panic
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;userID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserSource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unchecked type assertions appeared six times. As long as the bot is pulled into a group and someone sends an image, this line panics.&lt;/p&gt;

&lt;p&gt;By the way, several other places in the same file used &lt;code&gt;e.Source.(*webhook.GroupSource)&lt;/code&gt; (pointer). Checking the SDK's &lt;code&gt;UnmarshalSource&lt;/code&gt;, it returns a &lt;strong&gt;value&lt;/strong&gt;, not a pointer, so those assertions with &lt;code&gt;, ok&lt;/code&gt; were always false—also dead code. Two different wrong ways in the same file, in opposite directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. /recent_files returns folders
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// When uploading: file is placed in LINE Bot Uploads/YYYY-MM/&lt;/span&gt;
&lt;span class="n"&gt;monthFolderID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;findOrCreateFolder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2026-08"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mainFolderID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Files&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;drive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Parents&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;monthFolderID&lt;/span&gt;&lt;span class="p"&gt;}})&lt;/span&gt;

&lt;span class="c"&gt;// When querying: only look under LINE Bot Uploads&lt;/span&gt;
&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"'%s' in parents and trashed=false"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mainFolderID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Files are stored in monthly subfolders, but the query only looks at the root folder. In the Google Drive data model, a folder is also a type of file, so this query does return things—it returns the &lt;code&gt;2026-08&lt;/code&gt;, &lt;code&gt;2026-07&lt;/code&gt; folders themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. User input directly concatenated into the Drive query
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"... and name contains '%s'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;searchQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No escaping. If a user searches for &lt;code&gt;it's&lt;/code&gt;, that single quote breaks the query syntax; thinking further, extra query conditions could be injected. The fix is to properly write an escaping function, noting that the order cannot be reversed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Backslashes must be escaped first, otherwise the backslash added &lt;/span&gt;
&lt;span class="c"&gt;// to escape a quote will be escaped again in the second round of processing.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;escapeDriveQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`\`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`\\`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&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;h2&gt;
  
  
  5. /quit treated as a search command
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;13&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;13&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"/search_files"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
          &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"/q"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;commandPrefixLen&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;commandPrefixLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;14&lt;/span&gt; &lt;span class="c"&gt;// Length of "/search_files "&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;commandPrefixLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="c"&gt;// Length of "/q "&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;searchQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;commandPrefixLen&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Manual string slicing, and it hardcoded "must be followed by a space." If a user types &lt;code&gt;/quit&lt;/code&gt;, the first two characters are &lt;code&gt;/q&lt;/code&gt;, so it becomes a search for &lt;code&gt;it&lt;/code&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  What go-modern-guidelines actually changed
&lt;/h1&gt;

&lt;p&gt;Back to the topic. Out of those 45 guidelines from &lt;code&gt;list&lt;/code&gt;, these were the ones actually applied in this change:&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;http_servemux_patterns&lt;/code&gt;: Also removed a piece of manual path checking
&lt;/h2&gt;

&lt;p&gt;The original way was to have all requests go to the same handler and then determine the path manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// LINE Platform must POST to the webhook URL&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After Go 1.22, &lt;code&gt;ServeMux&lt;/code&gt; patterns support methods and exact paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;mux&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServeMux&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c"&gt;// "/{$}" only matches the root path, not all paths beneath it&lt;/span&gt;
&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"POST /{$}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webhookHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GET /oauth/callback"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;oauthCallbackHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// Cannot be called /healthz, see Pitfall 5 for the reason&lt;/span&gt;
&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GET /health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;healthHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;{$}&lt;/code&gt; syntax is key: &lt;code&gt;"/"&lt;/code&gt; in &lt;code&gt;ServeMux&lt;/code&gt; is a subtree pattern that consumes all paths beneath it, which is why the original code needed that manual check. &lt;code&gt;"/{$}"&lt;/code&gt; only matches the root path itself, so the check is no longer needed. I also added method restrictions and a health check endpoint while I was at it.&lt;/p&gt;

&lt;p&gt;The health check endpoint issue came up later, but that was discovered after deployment; I'll save that for Pitfall 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;cmp_or&lt;/code&gt;: Three fallback segments turned into three lines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Before&lt;/span&gt;
&lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PORT"&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;port&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"5000"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// After (also changed default to 8080 to align with Dockerfile EXPOSE and Cloud Run convention)&lt;/span&gt;
&lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;cmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PORT"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"8080"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;richMenuConnect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RICH_MENU_CONNECT"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;defaultRichMenuConnect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;richMenuMain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RICH_MENU_MAIN"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;defaultRichMenuMain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This exactly fits the usage conditions reminded by &lt;code&gt;explain&lt;/code&gt;: all three parameters are &lt;code&gt;os.Getenv&lt;/code&gt; and constants, and evaluating them all has no side effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;strings_cut_prefix_suffix&lt;/code&gt;: Replacing manual string slicing
&lt;/h2&gt;

&lt;p&gt;The command parsing from point five earlier, &lt;code&gt;message.Text[:13]&lt;/code&gt;, was replaced with a proper parsing function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;parseCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arg&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;cmdConnect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmdReconnect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmdDisconnect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmdRecent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmdSearch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmdSearchShort&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing to &lt;code&gt;strings.Cut&lt;/code&gt; to first slice out the full command name and then using a switch for comparison structurally eliminated the &lt;code&gt;/quit&lt;/code&gt; bug—the sliced name is &lt;code&gt;/quit&lt;/code&gt;, which isn't in the allowed list, so it returns false directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;slices_sort_func&lt;/code&gt; + &lt;code&gt;min&lt;/code&gt;: Fixing that fake sorting
&lt;/h2&gt;

&lt;p&gt;The original search results looked like this after deduplication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Remove duplicates and sort by creation time (newest first)&lt;/span&gt;
&lt;span class="n"&gt;uniqueFiles&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;drive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;File&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;files&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;uniqueFiles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;uniqueFiles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;drive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uniqueFiles&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;uniqueFiles&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file&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="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment says "sort by creation time (newest first)", but in reality, there was no sorting action at all—&lt;strong&gt;map iteration order is random&lt;/strong&gt;, and then it just truncated the first 10 items. So users got 10 random items, not the 10 newest ones.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// createdTime returned by Drive is an RFC 3339 UTC string; direct string comparison is the correct chronological order&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;sortAndTrimFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;drive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;drive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SortStableFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;drive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;limit&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 &lt;code&gt;min&lt;/code&gt; built-in function (Go 1.21) saves an &lt;code&gt;if&lt;/code&gt; here.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;any&lt;/code&gt;, &lt;code&gt;errors_is&lt;/code&gt;: Small details
&lt;/h2&gt;

&lt;p&gt;Changing &lt;code&gt;map[string]interface{}&lt;/code&gt; to &lt;code&gt;map[string]any&lt;/code&gt;; no need to say more about such one-liners.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;crypto/rand.Text()&lt;/code&gt;: A suggestion that requires a version upgrade first
&lt;/h2&gt;

&lt;p&gt;Original way to generate OAuth state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;generateState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rand&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// Error ignored&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URLEncoding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;crypto/rand.Text()&lt;/code&gt;, added in Go 1.24, returns a random string directly, won't fail, and the output is base32 (&lt;code&gt;A-Z&lt;/code&gt;, &lt;code&gt;2-7&lt;/code&gt;), which is naturally URL-safe—perfect for states and Firestore document IDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;generateState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rand&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there's a prerequisite for this one, which I'll discuss below.&lt;/p&gt;




&lt;h1&gt;
  
  
  Major Pitfalls and Solutions
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Pitfall 1: The skill explicitly tells you not to grep, and I did exactly the opposite the first time
&lt;/h2&gt;

&lt;p&gt;The skill documentation is clear:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not pipe the output through head, tail, grep, sed, or any other truncating/filtering command. Important guidelines may otherwise be missed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first time I called it, I typed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go-modern-guidelines list &lt;span class="nt"&gt;--file-path&lt;/span&gt; main.go 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-60&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Purely a reflex to avoid flooding the screen with long output. In hindsight, I realized this was dangerous on two levels: first, &lt;code&gt;list&lt;/code&gt; explicitly states it is &lt;strong&gt;sorted from newest to oldest&lt;/strong&gt;, so &lt;code&gt;tail&lt;/code&gt; gets exactly the oldest batch; second, I only got away with it this time because the &lt;code&gt;go.mod&lt;/code&gt; specified 1.23, totaling 41 lines, which is less than 60, so &lt;code&gt;tail -60&lt;/code&gt; printed everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Pure luck. If the project had been Go 1.27 (54 guidelines), &lt;code&gt;tail -60&lt;/code&gt; still wouldn't have truncated; but if I had typed &lt;code&gt;head -20&lt;/code&gt; or &lt;code&gt;grep slices&lt;/code&gt;, I would have missed entire batches of items with absolutely no hint that I missed anything. This kind of "output truncated but looks normal" failure is the hardest to detect. Just read the full output honestly; it's only 45 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: Your go.mod might not allow the syntax suggested by the tool
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rand.Text()&lt;/code&gt; appeared in the suggestion list, but the project's &lt;code&gt;go.mod&lt;/code&gt; at the time was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="err"&gt;module&lt;/span&gt; &lt;span class="err"&gt;github.com/kkdai/linebot-file&lt;/span&gt;

&lt;span class="err"&gt;//&lt;/span&gt; &lt;span class="err"&gt;+heroku&lt;/span&gt; &lt;span class="err"&gt;goVersion&lt;/span&gt; &lt;span class="err"&gt;go1.21&lt;/span&gt;
&lt;span class="err"&gt;go&lt;/span&gt; &lt;span class="err"&gt;1.23.0&lt;/span&gt;

&lt;span class="err"&gt;toolchain&lt;/span&gt; &lt;span class="err"&gt;go1.24.3&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;go 1.23.0&lt;/code&gt; line determines the &lt;strong&gt;language version&lt;/strong&gt;, which is different from the &lt;code&gt;toolchain&lt;/code&gt;. The tool answers based on the version it can parse, but to actually use &lt;code&gt;rand.Text()&lt;/code&gt;, you have to modify &lt;code&gt;go.mod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's not just about brainlessly changing one line; you have to ensure everything in the chain aligns: the &lt;code&gt;toolchain&lt;/code&gt; was already &lt;code&gt;go1.24.3&lt;/code&gt;, the Dockerfile used &lt;code&gt;golang:1.24-alpine&lt;/code&gt;, both were fine. However, the CI had an issue—&lt;code&gt;.github/workflows/go.yml&lt;/code&gt; hardcoded &lt;code&gt;go-version: '1.22'&lt;/code&gt;, which is older than what &lt;code&gt;go.mod&lt;/code&gt; required; it was currently only not breaking because of Go's automatic toolchain download mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Updated &lt;code&gt;go.mod&lt;/code&gt; to &lt;code&gt;go 1.24.0&lt;/code&gt;, cleared out that outdated &lt;code&gt;// +heroku goVersion go1.21&lt;/code&gt; line (this project has long been running on Cloud Run), and changed the CI to use &lt;code&gt;go.mod&lt;/code&gt; as the single source of truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-go@v5&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Use go.mod as the single source of truth to avoid CI and project version inconsistency&lt;/span&gt;
    &lt;span class="na"&gt;go-version-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;go.mod&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pitfall 3: After changing go.mod, the tool's answers changed
&lt;/h2&gt;

&lt;p&gt;This was the most interesting discovery this time. After upgrading &lt;code&gt;go.mod&lt;/code&gt;, I ran &lt;code&gt;list&lt;/code&gt; again before writing tests and found four new items at the top of the list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;testing_t_context: Use t.Context() when a test function needs a context tied to
                   the test lifetime.
json_omitzero: Use omitzero on JSON-tagged bool, numeric, struct, and time
                   fields whose zero value should be omitted...
testing_b_loop: Use b.Loop() for the main loop in benchmark functions.
strings_split_seq: Use strings or bytes SplitSeq and FieldsSeq helpers...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These four are exactly what was added in Go 1.24. And &lt;code&gt;testing_t_context&lt;/code&gt; directly changed the test I was currently writing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Before&lt;/span&gt;
&lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;drive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewService&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="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithEndpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithoutAuthentication&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c"&gt;// After — context bound to test lifetime, automatically canceled when test ends&lt;/span&gt;
&lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;drive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&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;option&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithEndpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithoutAuthentication&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;Reason and Solution&lt;/strong&gt;: The output of this tool &lt;strong&gt;changes according to the project state&lt;/strong&gt;; it's not a static document. Upgrade the version or switch projects, and the answers change. So the correct usage isn't to check once before starting and be done, but to &lt;strong&gt;rerun it when the nature of the changes shifts&lt;/strong&gt;—in my case, I reran it at the junction of "finished main program, starting tests," and happened to catch &lt;code&gt;testing_t_context&lt;/code&gt;. If I had only checked at the very beginning, I would have missed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 4: The tool manages syntax, not architecture—and architectural pitfalls are deeper
&lt;/h2&gt;

&lt;p&gt;This is the reverse: where &lt;code&gt;go-modern-guidelines&lt;/code&gt; doesn't and shouldn't have an opinion.&lt;/p&gt;

&lt;p&gt;Originally, file uploads were done synchronously in the webhook handler: downloading a video from LINE and then uploading to Drive could take dozens of seconds. LINE expects a response within a certain time; if it times out, it retries, and retries would cause &lt;strong&gt;duplicate uploads of the same file&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The standard advice for this is almost reflexive: return 200 first, and throw the rest into a goroutine. I thought the same at first, but halfway through writing, I remembered something—&lt;strong&gt;this service runs on Cloud Run, which by default only allocates CPU during request processing.&lt;/strong&gt; Once the response is sent, that goroutine will be throttled by the CPU, becoming a black hole that looks like it's working but actually has no idea when it will finish. This is worse than synchronous processing; at least synchronous processing fails honestly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Changed to use the webhook's event ID for deduplication so that retries don't cause duplicate uploads, while keeping synchronous processing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// handledEvents remembers recently processed webhook event IDs. LINE will resend&lt;/span&gt;
&lt;span class="c"&gt;// requests it considers failed; without this protection, resending would upload the same file again.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;handledEvents&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;mu&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mutex&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;handledEvents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;markHandled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&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;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt; &lt;span class="c"&gt;// Without an ID, there's no way to deduplicate, so treat as a new event&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// ... clear expired ones, then check for duplicates&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Added a fallback of "if the reply token expires, use push message" so that the user is still notified after a large file finishes uploading.&lt;/p&gt;

&lt;p&gt;This is a compromise; the real solution is to use Cloud Tasks or Pub/Sub. I've added it to the project roadmap, including the reason "can't just use goroutines"—otherwise, the next person taking over (likely me in three months) will probably hit the same pitfall again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 5: ServeMux was written correctly, but Cloud Run won't let you use it
&lt;/h2&gt;

&lt;p&gt;After the PR was merged, I checked Cloud Build with &lt;code&gt;gcloud&lt;/code&gt;; the status was SUCCESS, the new revision was Ready, and all traffic was switched over. It looked like a job well done.&lt;/p&gt;

&lt;p&gt;Poked the endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;GET / 405 ← method-aware ServeMux in effect
POST / No signature 400 ← signature verification in effect
&lt;/span&gt;&lt;span class="gp"&gt;GET /nope 404 ← {$&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; exact match &lt;span class="k"&gt;in &lt;/span&gt;effect
&lt;span class="go"&gt;GET /healthz 404 ← ?

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first three were correct, but the health check returned 404.&lt;/p&gt;

&lt;p&gt;At first, I thought I wrote the route wrong, but I only realized something was off after printing the response content—it was a Google-branded HTML error page (&lt;code&gt;Error 404 (Not Found)!!1&lt;/code&gt;, with the Google robot image), not Go's &lt;code&gt;404 page not found&lt;/code&gt; plain text. This meant the request never even reached my program.&lt;/p&gt;

&lt;p&gt;Checking the Cloud Run request logs confirmed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;15:44:54 GET 400 /oauth/callback
15:44:36 GET 404 /nope
15:44:36 POST 400 /
15:44:36 GET 405 /

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I sent five requests, but there were only four in the log. The two &lt;code&gt;/healthz&lt;/code&gt; requests didn't even have a record.&lt;/p&gt;

&lt;p&gt;Scanning various common health check paths narrowed it down significantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/healthz 404 GFE(Google) ← Intercepted
/healthz/ 404 app(Go) ← Only one slash difference
/health 404 app(Go)
/readyz 404 app(Go)
/livez 404 app(Go)
/_ah/health 404 app(Go)
/status 404 app(Go)
/ping 404 app(Go)
/healthcheck 404 app(Go)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the exact path &lt;code&gt;/healthz&lt;/code&gt; was intercepted by Google Frontend; even adding a slash allowed it to reach the app normally. I looked it up and found this is a known behavior of Cloud Run, which &lt;a href="https://github.com/streamlit/streamlit/issues/3028" rel="noopener noreferrer"&gt;Streamlit&lt;/a&gt; and &lt;a href="https://github.com/n8n-io/n8n/issues/26163" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; have also encountered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Changed the endpoint to &lt;code&gt;/health&lt;/code&gt;, a one-line fix. The annoying part is that this pitfall doesn't make a sound—&lt;code&gt;go vet&lt;/code&gt; doesn't speak, tests don't speak, CI is all green, build succeeds, Cloud Run shows Ready, and even request logs leave no trace. The only way to find it is to actually poke the endpoint and notice that the returned 404 looks different from the one your program returns.&lt;/p&gt;

&lt;p&gt;So when fixing it, I left a comment in the code and wrote a section in the README:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Not "/healthz": Cloud Run's frontend reserves that exact path and&lt;/span&gt;
&lt;span class="c"&gt;// answers it with its own 404, so the request never reaches us.&lt;/span&gt;
&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GET /health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&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;Without this line, the next person who sees &lt;code&gt;/health&lt;/code&gt; and thinks "this isn't the convention, it should be healthz" (very likely myself) will change it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 6: I thought I added context to all external calls, but I missed an entire path
&lt;/h2&gt;

&lt;p&gt;While fixing &lt;code&gt;/healthz&lt;/code&gt;, I scanned the code again and found something even more embarrassing.&lt;/p&gt;

&lt;p&gt;One change I was very satisfied with earlier was "using context throughout the process, with timeouts for all external calls." Firestore had it, Drive had it. Then I grepped all external calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;webhook.go:312 blob.GetMessageContent(messageID)
line.go:73 bot.ReplyMessage(...)
line.go:94 bot.PushMessage(...)
line.go:118 bot.LinkRichMenuIdToUser(...)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four LINE calls, none of which took a context. I had to dig into the SDK to find out why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;MessagingApiAPI&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;channelToken&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;channelToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// ← Timeout is zero, meaning no timeout&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the method signatures generated by the SDK don't accept &lt;code&gt;context&lt;/code&gt;, so the timeout I wrapped in the outer handler had absolutely no effect on these four calls. If the LINE side hangs, the goroutine just hangs indefinitely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: The problem wasn't that I didn't know to add a timeout, but that the memory of "I've added context" overrode the fact of "whether this SDK actually accepts context." When modifying Drive and Firestore, I added &lt;code&gt;.Context(ctx)&lt;/code&gt; all the way down very smoothly—so smoothly that I didn't stop to think about which other external calls didn't look like that.&lt;/p&gt;

&lt;p&gt;The SDK provides injection points:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;messaging_api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewMessagingApiAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messaging_api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithHTTPClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lineAPITimeout&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="c"&gt;// 10 seconds&lt;/span&gt;

&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;messaging_api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewMessagingApiBlobAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messaging_api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithBlobHTTPClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lineBlobTimeout&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="c"&gt;// 5 minutes&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I gave the blob side 5 minutes because it needs to download videos sent by users.&lt;/p&gt;

&lt;p&gt;Along with a more hidden trap. The SDK provides something that looks exactly like what I wanted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;MessagingApiAPI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;MessagingApiAPI&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It directly overwrites the field of a shared structure and then returns the same pointer. My &lt;code&gt;bot&lt;/code&gt; is a package-level shared variable; if multiple requests come in simultaneously and each calls &lt;code&gt;WithContext&lt;/code&gt;, it's a standard data race. The name sounds like a functional option, but the behavior is mutation. I also left a comment for this line in the code to prevent someone from changing it later thinking it's more precise than &lt;code&gt;WithHTTPClient&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 7: Bugs that don't make a sound need to be actively hit by tests
&lt;/h2&gt;

&lt;p&gt;There was another one found in the same round. The &lt;code&gt;uploadParents&lt;/code&gt; function is responsible for listing all &lt;code&gt;YYYY-MM&lt;/code&gt; month folders, originally written like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Files&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Q&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"files(id)"&lt;/span&gt;&lt;span class="p"&gt;)&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;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;PageSize&lt;/code&gt; set. The Drive API defaults to 100 items per page; anything beyond that requires asking again with a &lt;code&gt;nextPageToken&lt;/code&gt;. One folder is generated per month, so after 100 months—about 8 years and 4 months—the oldest folders would disappear from the scope of searches and &lt;code&gt;/recent_files&lt;/code&gt;. There would be no error, no warning, just fewer results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;: Changed to use &lt;code&gt;Pages()&lt;/code&gt; to iterate through all pages. What I really want to talk about is the next step. I didn't quite trust the pagination logic I just wrote, so I wrote a mock server that returns a &lt;code&gt;nextPageToken&lt;/code&gt; and then temporarily changed the implementation back to only fetch the first page to see if the test would fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- FAIL: TestUploadParentsPagesThroughAllSubfolders
    uploadParents() = [root_id month_1 month_2], want [root_id month_1 month_2 month_3]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirmed it would fail before restoring the implementation. This step took less than two minutes, but without it, I would only have a "ran and passed" test without knowing if it was actually testing anything. Bugs like silent truncation don't reveal themselves; if the test is also a green illusion, you have nothing.&lt;/p&gt;




&lt;h1&gt;
  
  
  Results and Benefits
&lt;/h1&gt;

&lt;p&gt;First, the most direct numbers. Originally, &lt;code&gt;main.go&lt;/code&gt; was 1039 lines and &lt;code&gt;main()&lt;/code&gt; was 564 lines. After splitting into six files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;main.go&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;td&gt;Startup, environment variable checks, routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;config.go&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;Constants and shared state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;webhook.go&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;344&lt;/td&gt;
&lt;td&gt;Event dispatching, command parsing, command handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;line.go&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;164&lt;/td&gt;
&lt;td&gt;LINE message assembly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;drive.go&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;td&gt;Drive query/upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth.go&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;264&lt;/td&gt;
&lt;td&gt;OAuth, token, revocation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;main()&lt;/code&gt; went from 564 lines to 62 lines. Interestingly, &lt;strong&gt;the total lines of main code barely changed&lt;/strong&gt; (1039 → 1123); what significantly increased was the tests: from 88 lines to 468 lines, and the number of tests from 1 to 16, all passing under &lt;code&gt;-race&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In terms of performance, the search function was originally "find the root folder, then check each monthly subfolder one by one," which is &lt;code&gt;1 + N&lt;/code&gt; Drive API calls; after changing to concatenate all parents with &lt;code&gt;or&lt;/code&gt; into a single query, it's fixed at 2 calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The value of &lt;code&gt;go-modern-guidelines&lt;/code&gt; isn't that it taught me syntax I'd never seen.&lt;/strong&gt; I generally knew about &lt;code&gt;cmp.Or&lt;/code&gt;, &lt;code&gt;min&lt;/code&gt;, and &lt;code&gt;slices.SortFunc&lt;/code&gt;; the problem is that I don't actively think of them while coding—especially when modifying an existing file, where the surrounding old syntax creates a kind of gravity, making it natural to continue writing in the same style. A sentence in the skill documentation hits the mark:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a guideline applies, follow it even when nearby code or repository convention uses an older pattern.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This sentence is fighting the frequency bias mentioned earlier, and it applies to humans too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Its boundaries are also very clear.&lt;/strong&gt; Those five bugs that actually bite users—the wrong-level switch, the panicking type assertion, the query returning folders, the unescaped query, the misjudged &lt;code&gt;/quit&lt;/code&gt;—none of them were caught by &lt;code&gt;go-modern-guidelines&lt;/code&gt;; that's not its defensive scope. It manages whether "this Go code is contemporary enough," not whether "this logic is correct." Treat it as a supplement to a linter, not a replacement for code review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first half of this article was written before deployment.&lt;/strong&gt; The &lt;code&gt;/healthz&lt;/code&gt; pitfall was discovered after the article was finished and the PR merged, when I checked the build status on &lt;code&gt;gcloud&lt;/code&gt;. The situation was: all 45 guidelines checked, everything applicable applied, 17 tests passed under &lt;code&gt;-race&lt;/code&gt;, all three CI checks green, Cloud Build SUCCESS, and Cloud Run showing Ready with 100% traffic switched. In this entire row of green lights, not a single one told you an endpoint was dead.&lt;/p&gt;

&lt;p&gt;I wrote in &lt;a href="https://dev.to/help-handle-cloudflare/"&gt;the previous post about handling Cloudflare&lt;/a&gt; that "a successful build cannot be taken as verification," and I thought I remembered it, but I still paid tuition in the same place this time, just on a different layer—last time the build succeeded but the container crashed on startup; this time the container ran fine but was eaten by the outer infrastructure layer. Tools manage syntax, tests manage logic, CI manages whether these two have regressed, but none of them manage "what happens when this thing is deployed to that specific environment." That part you have to poke yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep in mind that the output changes with the project state.&lt;/strong&gt; The phenomenon in Pitfall 3 where "four more suggestions appeared after upgrading go.mod" was the most practical realization this time. It's not a static document to be checked once, but a query interface that answers based on the project's current state. When the nature of the changes shifts (from main program to tests, language version upgrade, project switch), it's worth running again.&lt;/p&gt;

&lt;p&gt;Finally, this change is in &lt;a href="https://github.com/kkdai/linebot-file/pull/4" rel="noopener noreferrer"&gt;PR #4&lt;/a&gt;, the two fixes added after deployment are in &lt;a href="https://github.com/kkdai/linebot-file/pull/5" rel="noopener noreferrer"&gt;#5&lt;/a&gt; and &lt;a href="https://github.com/kkdai/linebot-file/pull/6" rel="noopener noreferrer"&gt;#6&lt;/a&gt;, and the code is at &lt;a href="https://github.com/kkdai/linebot-file" rel="noopener noreferrer"&gt;kkdai/linebot-file&lt;/a&gt;. The source code for &lt;code&gt;go-modern-guidelines&lt;/code&gt; is at &lt;a href="https://github.com/JetBrains/go-modern-guidelines" rel="noopener noreferrer"&gt;JetBrains/go-modern-guidelines&lt;/a&gt;, licensed under Apache 2.0.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>go</category>
      <category>programming</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>[LINE Bot Debugging] A 403 Error and Three Underlying Issues: npm Version Drift, Node.js Requirements, and Cloudflare Verificati</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Thu, 27 Aug 2026 02:46:14 +0000</pubDate>
      <link>https://dev.to/evanlin/line-bot-debugging-a-403-error-and-three-underlying-issues-npm-version-drift-nodejs-4886</link>
      <guid>https://dev.to/evanlin/line-bot-debugging-a-403-error-and-three-underlying-issues-npm-version-drift-nodejs-4886</guid>
      <description>&lt;h1&gt;
  
  
  Previously
&lt;/h1&gt;

&lt;p&gt;"URL retrieval failed, help me check the logs."&lt;/p&gt;

&lt;p&gt;I receive this kind of report about once every week or two. Usually, it's because a website has added another layer of anti-crawling protection. A quick check and adding a fallback method to bypass it usually settles it. This time started the same way: a user sent an &lt;code&gt;acm.org&lt;/code&gt; URL, and the summary feature spat out an error.&lt;/p&gt;

&lt;p&gt;Upon investigation, I realized it wasn't that simple. The URL retrieval feature had been silently broken for three days. The problem wasn't the target website; it was an unlocked npm package version in my own Docker image. One day, it quietly upgraded to a new version that required a newer Node.js to run, while the Node.js in the container was still stuck on the version installed six months ago.&lt;/p&gt;

&lt;p&gt;First, let's look at the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud logging &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s1"&gt;'resource.type="cloud_run_revision" AND \
  resource.labels.service_name="linebot-helper-python" AND \
  textPayload:"acm.org"'&lt;/span&gt; &lt;span class="nt"&gt;--limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50 &lt;span class="nt"&gt;--freshness&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2d &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"table(timestamp,severity,textPayload)"&lt;/span&gt;


ERROR:loader.url:All methods failed &lt;span class="k"&gt;for &lt;/span&gt;URL: https://www.acm.org/articles/people-of-acm/2026/russ-cox
WARNING:loader.url:cloudscraper failed ...: 403 Client Error: Forbidden &lt;span class="k"&gt;for &lt;/span&gt;url: ...
WARNING:loader.url:httpx failed ...: Client error &lt;span class="s1"&gt;'403 Forbidden'&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;url: ...
WARNING:loader.url:singlefile failed ...: SingleFile exited with code 1
ERROR:loader.singlefile:SingleFile loading failed ...: SingleFile exited with code 1

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Bot has a fallback chain for grabbing webpage content: first try singlefile (full rendering with headless Chromium), if that fails switch to httpx, and if that fails switch to cloudscraper (a tool specifically for bypassing Cloudflare). It only reports an error to the user if all three fail. It's understandable for httpx and cloudscraper to get a 403; acm.org has protection. But singlefile shouldn't fail; it's designed as the last line of defense to bypass such protections.&lt;/p&gt;

&lt;p&gt;Scrolling down, I found the actual error for singlefile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: WebSocket is not available, Node.js 22.4.0 or later is required
    at file:///usr/local/lib/node_modules/single-file-cli/lib/deno-polyfill.js:132:8
Node.js v18.20.4

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container was running Node 18, but single-file-cli required 22.4 or later. Checking the logs, this error first appeared three days ago, not today. This means for the past three days, if any of the first two methods were blocked for a URL, the whole process would fail. Users would only receive a message saying "Unable to read content from the URL," with no indication of the underlying cause.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why Run Cloud Build Three Times Instead of Deploying Immediately After Changes
&lt;/h1&gt;

&lt;p&gt;The rest of this article will feature three cycles of "modify Dockerfile → build with Cloud Build → run and see." Initially, I didn't plan on doing this; I thought changing a version number and seeing the build pass would be enough to deploy. I later found this assumption was wrong every single time.&lt;/p&gt;

&lt;p&gt;The reason is that for commands like &lt;code&gt;npm install -g single-file-cli&lt;/code&gt;, a successful build only means "npm found the package and could install it," not "the package works when it runs." npm's check for the &lt;code&gt;engines&lt;/code&gt; field is just a warning by default and won't fail the build. Whether the package actually crashes won't be known until it tries to launch a headless Chromium and establish a WebSocket connection. There's a gap in between that the build log doesn't show at all.&lt;/p&gt;

&lt;p&gt;So this time, I didn't stop at "build passed." Every time I changed a version, I ran a shell script directly in Cloud Build to use the installed &lt;code&gt;single-file&lt;/code&gt; in the container to actually fetch the &lt;code&gt;acm.org&lt;/code&gt; URL that was failing. This habit later saved me twice: if I had deployed just because the build succeeded, the Dockerfile changes would have eventually re-enacted the exact same crash in the production environment, just with a different error message.&lt;/p&gt;




&lt;h1&gt;
  
  
  System Architecture
&lt;/h1&gt;

&lt;p&gt;After the fix, the URL retrieval failure handling looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    A[User sends URL] --&amp;gt; B[loader/url.py fallback chain]
    B --&amp;gt; C1[singlefile: headless Chromium]
    C1 --&amp;gt;|Content fetched| D{is_challenge_page check}
    D --&amp;gt;|Is Cloudflare challenge page| E[Treat as failure, move to next method]
    D --&amp;gt;|Is real content| F[Return to Gemini]
    C1 --&amp;gt;|Crash or timeout| E
    E --&amp;gt; C2[httpx]
    C2 --&amp;gt;|403 or challenge page| C3[cloudscraper]
    C2 --&amp;gt;|Real content| F
    C3 --&amp;gt;|403 or challenge page| G[All three failed]
    C3 --&amp;gt;|Real content| F
    G --&amp;gt; H[Throw Chinese error message to user]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Originally, only the "crash or timeout" path was considered a failure; the &lt;code&gt;is_challenge_page&lt;/code&gt; branch was newly added this time, and I'll explain why later.&lt;/p&gt;




&lt;h1&gt;
  
  
  Core Implementation
&lt;/h1&gt;

&lt;h2&gt;
  
  
  1. Dockerfile: Locking Versions for Both Node.js and single-file-cli
&lt;/h2&gt;

&lt;p&gt;Before the fix, this &lt;code&gt;RUN&lt;/code&gt; command looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;        nodejs &lt;span class="se"&gt;\
&lt;/span&gt;        npm &lt;span class="se"&gt;\
&lt;/span&gt;        git &lt;span class="se"&gt;\
&lt;/span&gt;        chromium &lt;span class="se"&gt;\
&lt;/span&gt;        ffmpeg &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; single-file-cli &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get clean &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both problems were in these lines. &lt;code&gt;apt-get install nodejs&lt;/code&gt; pulls the version from the built-in Debian apt source, which is stuck on v18. &lt;code&gt;npm install -g single-file-cli&lt;/code&gt; had no version lock, so every image rebuild would pull the latest version currently on npm. Separately, these aren't severe, but together they are a ticking time bomb: I have no control over when single-file-cli increases its Node.js requirement, while my container version was fixed.&lt;/p&gt;

&lt;p&gt;After the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Lock both Node.js and single-file-cli versions to avoid silently pulling incompatible new versions during future rebuilds:&lt;/span&gt;
&lt;span class="c"&gt;# - Node.js: Switched to NodeSource 24.x (Debian's built-in v18 is too old), locking the exact version.&lt;/span&gt;
&lt;span class="c"&gt;# The ws/simple-cdp dependency of single-file-cli requires a global CloseEvent, which is only available in Node 24 &lt;/span&gt;
&lt;span class="c"&gt;# (not in Node 22, even with --experimental-websocket, as verified by testing). This was the root cause of the acm.org scraping failure.&lt;/span&gt;
&lt;span class="c"&gt;# - single-file-cli: Locked at 2.0.83, verified to work correctly under Node 24.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;        ca-certificates &lt;span class="se"&gt;\
&lt;/span&gt;        curl &lt;span class="se"&gt;\
&lt;/span&gt;        gnupg &lt;span class="se"&gt;\
&lt;/span&gt;        git &lt;span class="se"&gt;\
&lt;/span&gt;        chromium &lt;span class="se"&gt;\
&lt;/span&gt;        ffmpeg &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/apt/keyrings &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/nodesource.gpg &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/apt/sources.list.d/nodesource.list &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="nv"&gt;nodejs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;24.19.0-1nodesource1 &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; single-file-cli@2.0.83 &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get clean &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact version numbers &lt;code&gt;nodejs=24.19.0-1nodesource1&lt;/code&gt; and &lt;code&gt;single-file-cli@2.0.83&lt;/code&gt; weren't chosen at random; they were determined through three rounds of Cloud Build testing. I'll explain how I arrived at these numbers in the "Major Pitfalls" section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Detecting Cloudflare Challenge Pages
&lt;/h2&gt;

&lt;p&gt;After singlefile stopped crashing, I thought it was over until I actually used it to fetch &lt;code&gt;acm.org&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== single-file fetch acm.org (headless chromium) ==
OK: 18400 bytes
&amp;lt;title&amp;gt;Attention Required! | Cloudflare&amp;lt;/title&amp;gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It "succeeded," but what it fetched was the Cloudflare human verification page, not the article itself. The problem was that this feature had no detection for this situation. While httpx and cloudscraper would call &lt;code&gt;raise_for_status()&lt;/code&gt; on 4xx/5xx and fail immediately, the page rendered by headless Chromium is always an HTTP 200 (the browser displays whatever it gets). This layer of check didn't exist for singlefile. The verification page text would be sent to Gemini, and the user would receive a nonsensical summary completely unrelated to the original article, appearing as a normal response without any error prompts.&lt;/p&gt;

&lt;p&gt;I added the detection logic in &lt;code&gt;loader/html.py&lt;/code&gt;:&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="c1"&gt;# Common characteristic strings for Cloudflare/WAF human verification pages. 
# singlefile uses headless Chromium to render pages, and unlike httpx/cloudscraper, 
# it doesn't call raise_for_status() for 4xx/5xx. It treats fetching a challenge page 
# as a "successful fetch"—this specifically intercepts that case. If detected, it must 
# be treated as a failure so the fallback chain in loader/url.py moves to the next method, 
# preventing challenge page content from being sent to Gemini.
&lt;/span&gt;&lt;span class="n"&gt;CHALLENGE_PAGE_MARKERS&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;Attention Required! | Cloudflare&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;Just a moment...&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;Checking your browser before accessing&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;cf-browser-verification&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;Enable JavaScript and cookies to continue&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;DDoS protection by Cloudflare&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;def&lt;/span&gt; &lt;span class="nf"&gt;is_challenge_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Check if the fetched content is a Cloudflare/WAF challenge page rather than real content.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CHALLENGE_PAGE_MARKERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three loaders (httpx, cloudscraper, singlefile) now use this check. If a challenge page is caught, it's treated as a failure, allowing the fallback chain to try the next method:&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;if&lt;/span&gt; &lt;span class="nf"&gt;is_challenge_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;httpx got a bot-challenge page instead of real content: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&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="nf"&gt;parse_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;markdown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Major Pitfalls and Solutions
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Don't Take Version Numbers in Error Messages at Face Value
&lt;/h2&gt;

&lt;p&gt;Initially, seeing &lt;code&gt;Node.js 22.4.0 or later is required&lt;/code&gt;, I naturally upgraded the container to Node 22, and the build passed smoothly. I thought I was done until I actually ran single-file to fetch acm.org and got a completely different error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ReferenceError: CloseEvent is not defined
    at #onClose (.../single-file-cli/node_modules/simple-cdp/mod.js:334:36)
Node.js v22.23.2

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ws&lt;/code&gt; package that single-file-cli depends on tries to &lt;code&gt;new CloseEvent(...)&lt;/code&gt; when handling connection close events, which requires a global &lt;code&gt;CloseEvent&lt;/code&gt; class in the execution environment. I ran a probe directly in the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;node -e "console.log('WebSocket', typeof WebSocket, 'CloseEvent', typeof CloseEvent)"
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;WebSocket &lt;span class="k"&gt;function &lt;/span&gt;CloseEvent undefined
&lt;span class="go"&gt;
node --experimental-websocket -e "console.log('CloseEvent', typeof CloseEvent)"
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;CloseEvent undefined
&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node 22.23.2 has a global &lt;code&gt;WebSocket&lt;/code&gt; (which is what the "22.4.0 or later" error message was actually checking), but it doesn't have &lt;code&gt;CloseEvent&lt;/code&gt;, even with the experimental flag. Testing again with Node 24.19.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;node -e "console.log('CloseEvent', typeof CloseEvent)"
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;CloseEvent &lt;span class="k"&gt;function&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cause and Solution&lt;/strong&gt;: The internal version check in single-file-cli (the "Node 22.4 or later" in &lt;code&gt;deno-polyfill.js&lt;/code&gt;) only checks the first API it uses; it doesn't mean all APIs used by its dependencies are present in that version. The real blocker was the downstream &lt;code&gt;CloseEvent&lt;/code&gt;, which only became globally available in Node 24. Treat version numbers in error messages as a starting point, not the final answer. You only know where it will actually get stuck by running it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: The "Latest Version" on npm is a Moving Target
&lt;/h2&gt;

&lt;p&gt;After upgrading to Node 22, I thought it was over until a user asked to "lock the versions while you're at it to avoid future issues." Checking the current single-file-cli info on npm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm view single-file-cli engines
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt; deno: &lt;span class="s1"&gt;'&amp;gt;=2.2'&lt;/span&gt;, bun: &lt;span class="s1"&gt;'&amp;gt;=1.2'&lt;/span&gt;, node: &lt;span class="s1"&gt;'&amp;gt;=24.0.0'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The latest version requires Node 24 or higher, not the 22 I had just fixed. Checking the release times, I realized there were two consecutive version jumps within the same week:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm view single-file-cli time --json | grep -E "2\.0\.83|2\.1\.0"
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0.83"&lt;/span&gt;: &lt;span class="s2"&gt;"2025-11-29T23:34:16.671Z"&lt;/span&gt;
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.1.0"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-08-16T15:56:28.819Z"&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Version 2.0.83 was from last November, with &lt;code&gt;engines&lt;/code&gt; set to &lt;code&gt;node &amp;gt;= 20&lt;/code&gt;, and it worked stably for over six months. Version 2.1.0, released on August 16th, suddenly raised the bar to &lt;code&gt;node &amp;gt;= 24&lt;/code&gt;. I first observed the crash on August 20th, which aligned perfectly with an image rebuild: &lt;code&gt;npm install -g single-file-cli&lt;/code&gt; without a specified version had silently pulled the 2.1.0 version released just days prior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause and Solution&lt;/strong&gt;: &lt;code&gt;npm install -g &amp;lt;package&amp;gt;&lt;/code&gt; (without a version) in CI/CD or a Dockerfile essentially means "every rebuild might install something different." This becomes a problem the moment a package maintainer raises the Node version requirement. Locking versions isn't about being conservative; it's about changing "when this container breaks" from "whenever the package author feels like it" to "whenever I decide to upgrade." I eventually chose 2.0.83—not the latest, but the last stable version explicitly compatible with Node 20—rather than trying to meet the new Node 24 requirement of 2.1.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: Build Success Does Not Equal Runtime Success
&lt;/h2&gt;

&lt;p&gt;I mentioned this earlier in "Why Run Cloud Build Three Times," but here is how it actually looked. After locking Node 24 + single-file-cli 2.0.83, &lt;code&gt;gcloud builds submit&lt;/code&gt; finished successfully, and &lt;code&gt;Successfully built&lt;/code&gt; was printed. If I had only looked at the build success message, I would have mistakenly thought the problem was solved. But the version that first passed the build was actually the Node 22 version, which would have exploded at runtime due to the &lt;code&gt;CloseEvent&lt;/code&gt; issue in Pitfall 1. Build success itself does not prove the content of the container actually works.&lt;/p&gt;

&lt;p&gt;I added an extra step to the Cloud Build configuration to run the newly built image immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gcr.io/cloud-builders/docker'&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;build'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-t'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;nodefix-verify'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;nodefix-verify'&lt;/span&gt;
  &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bash'&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-c'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;node -v&lt;/span&gt;
      &lt;span class="s"&gt;single-file --version&lt;/span&gt;
      &lt;span class="s"&gt;single-file --browser-executable-path=/usr/bin/chromium \&lt;/span&gt;
        &lt;span class="s"&gt;--browser-args='["--no-sandbox","--disable-dev-shm-usage"]' \&lt;/span&gt;
        &lt;span class="s"&gt;"https://www.acm.org/articles/people-of-acm/2026/russ-cox" /tmp/out.html \&lt;/span&gt;
        &lt;span class="s"&gt;&amp;amp;&amp;amp; echo "OK: $(wc -c &amp;lt; /tmp/out.html) bytes"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time I ran this, it caught the &lt;code&gt;CloseEvent is not defined&lt;/code&gt; error from Pitfall 1. Only on the second run (Node 24 + 2.0.83) did I finally see &lt;code&gt;OK: 18400 bytes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause and Solution&lt;/strong&gt;: &lt;code&gt;npm install&lt;/code&gt; only prints a warning for &lt;code&gt;engines&lt;/code&gt; mismatches by default; it doesn't fail the command. &lt;code&gt;docker build&lt;/code&gt; success only means the exit code of every command was 0. Together, this means there is no necessary correlation between "build passed" and "this thing works correctly." Including "actually run the target scenario" in the verification process is the only way to bridge this gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 4: Fetching Content Doesn't Mean Fetching the Right Content
&lt;/h2&gt;

&lt;p&gt;When the verification in Pitfall 3 printed &lt;code&gt;OK: 18400 bytes&lt;/code&gt;, I initially thought the problem was solved until I saw that within those 18,400 bytes, the &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; said &lt;code&gt;Attention Required! | Cloudflare&lt;/code&gt;. I realized that even if single-file-cli works perfectly and renders the page with headless Chromium, Cloudflare might still block the request because it detects a headless browser, returning a challenge page. To single-file-cli, this challenge page looks exactly like a normal article: it's HTML, it can be saved, and it counts as a "success."&lt;/p&gt;

&lt;p&gt;After adding the &lt;code&gt;is_challenge_page&lt;/code&gt; check, the first round of tests didn't all pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FAILED test_load_html_with_httpx_raises_on_challenge_page
FAILED test_load_html_with_cloudscraper_raises_on_challenge_page

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;| The logic seemed correct, yet the httpx/cloudscraper tests failed to catch the challenge page. Upon investigation, I found that &lt;code&gt;load_html_with_httpx&lt;/code&gt; in &lt;code&gt;loader/html.py&lt;/code&gt; converts HTML to markdown (using &lt;code&gt;markdownify&lt;/code&gt;) before returning it. &lt;code&gt;markdownify&lt;/code&gt; does not preserve tags like &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; which are in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; and not displayed as body text. My detection string "Attention Required! | Cloudflare" happened to be in that discarded &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, so it wasn't in the text after the markdown conversion. Even worse was &lt;code&gt;cf-browser-verification&lt;/code&gt;, which in a real Cloudflare page is a CSS ID (&lt;code&gt;&amp;lt;div id="cf-browser-verification"&amp;gt;&lt;/code&gt;). It only exists in HTML attributes, so neither markdown conversion nor plain text extraction (&lt;code&gt;get_text()&lt;/code&gt;) would include the attribute value as text content. |&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause and Solution&lt;/strong&gt;: Move the checkpoint from "processed text" to "raw HTML":&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="c1"&gt;# Some challenge page features (like cf-browser-verification) only appear in HTML attributes, 
# which markdownify/get_text won't catch. So, check the raw HTML before conversion.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_challenge_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;httpx got a bot-challenge page instead of real content: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&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="nf"&gt;parse_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;markdown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I made the same adjustment for singlefile. Originally, it used &lt;code&gt;BeautifulSoup&lt;/code&gt; to convert the file to plain text before checking. I changed it to read the raw bytes and check them first; only if it passes is it handed over to &lt;code&gt;BeautifulSoup&lt;/code&gt;:&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;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&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;fp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw_html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_challenge_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SingleFile got a bot-challenge page instead of real content: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&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;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&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;The lesson here is: when writing protection logic, you must be clear about which layer of data you are checking. The same content, after different conversions (HTML → markdown, HTML → plain text), will lose information asymmetrically: some words are visible, while others only exist in attributes, and both extraction methods might miss them.&lt;/p&gt;




&lt;h1&gt;
  
  
  Results and Benefits
&lt;/h1&gt;

&lt;p&gt;After the changes, I ran a real end-to-end test, calling the &lt;code&gt;load_url()&lt;/code&gt; function actually used in Python within the container against the same acm.org URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO:loader.url:Trying singlefile for URL: ...
ERROR:loader.singlefile:SingleFile loading failed ...: SingleFile got a bot-challenge page instead of real content: ...
WARNING:loader.url:singlefile failed ...
INFO:loader.url:Trying httpx for URL: ...
WARNING:loader.url:httpx failed ...: 403 Forbidden
INFO:loader.url:Trying cloudscraper for URL: ...
WARNING:loader.url:cloudscraper failed ...: 403 Forbidden
ERROR:loader.url:All methods failed for URL: ...
Raised as expected: Exception 無法從網址讀取內容，請確認網址是否正確或稍後再試 (Unable to read content from the URL, please verify the URL or try again later)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three methods still failed; the Cloudflare protection behind acm.org was not bypassed. That hasn't changed. What changed is &lt;em&gt;how&lt;/em&gt; it failed: previously, the singlefile crash dragged down the entire fallback chain, leaving the user unaware that three methods had even been attempted. Now, all three methods fail cleanly on their own, and the user receives a clear error message instead of a nonsensical summary that is actually a Cloudflare challenge page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locking versions solves "will it break again in the future," not "can the acm.org URL be fetched."&lt;/strong&gt; It's easy to conflate these two at first: once versions are locked and single-file-cli no longer crashes, it's tempting to think the problem is solved. But the Cloudflare hurdle was never the scope of this fix, nor is there a simple solution (bypassing it would likely require browser fingerprint spoofing, which has a poor ROI). What was achieved was the complete separation of "crawler blocked" and "my own container is broken." The latter should no longer recur due to npm version drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Build success" cannot be treated as verification passed.&lt;/strong&gt; This was the biggest takeaway. If I had only looked for red text in the build log after every Dockerfile change, I would have missed at least two issues: the &lt;code&gt;CloseEvent&lt;/code&gt; crash with Node 22, and the fact that Cloudflare challenge pages were being sent as body text even when singlefile worked perfectly. Both are "build okay, crash at runtime" types of problems that only become visible when running against a target URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protection logic must align with the actual data layer received.&lt;/strong&gt; The issue in Pitfall 4 where &lt;code&gt;markdownify&lt;/code&gt; swallowed the &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; would have likely gone unnoticed until a user reported the exact same problem again, had I not written a test and seen it &lt;code&gt;DID NOT RAISE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In total, four files were modified: &lt;code&gt;Dockerfile&lt;/code&gt;, &lt;code&gt;loader/html.py&lt;/code&gt;, &lt;code&gt;loader/singlefile.py&lt;/code&gt;, plus a new test &lt;code&gt;tests/test_challenge_page_detection.py&lt;/code&gt;. All 150 tests in the project passed. The code is available at &lt;a href="https://github.com/kkdai/linebot-helper-python" rel="noopener noreferrer"&gt;kkdai/linebot-helper-python&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>debugging</category>
      <category>docker</category>
      <category>node</category>
    </item>
    <item>
      <title>[Gemini API in Action] Adding a "Detailed Research Report" Button to a LINE Bot: Using Google Search Grounding to Turn Summaries</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:00:07 +0000</pubDate>
      <link>https://dev.to/gde/gemini-api-in-action-adding-a-detailed-research-report-button-to-a-line-bot-using-google-2cd0</link>
      <guid>https://dev.to/gde/gemini-api-in-action-adding-a-detailed-research-report-button-to-a-line-bot-using-google-2cd0</guid>
      <description>&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%2Fl3lqqw0fadv4ztnaqiif.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%2Fl3lqqw0fadv4ztnaqiif.png" alt="image-20260817204011772" width="800" height="1734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Background
&lt;/h1&gt;

&lt;p&gt;My LINE Bot has always had a summary feature: you drop a URL in, it crawls the content, generates a summary, and attaches a social media post draft along with a button to save it as a bookmark. This feature has been around since 2024, but it has always only solved the "what is this about" problem. I often find myself wanting to know three other things:&lt;/p&gt;

&lt;p&gt;What is the background context of the things discussed in this article? Have there been counter-arguments from others? Are the numbers mentioned sourced, or are they just the author's own claims?&lt;/p&gt;

&lt;p&gt;Summaries can't answer these because the input for a summary is only the article itself. The model has no other materials; if you ask it for a "critical analysis," it can only circle around the original text or start hallucinating.&lt;/p&gt;

&lt;p&gt;Google Search Grounding fills exactly this gap. I used it as a search assistant in &lt;a href="https://dev.to/evanlin/gemini-30google-search-building-a-news-and-information-assistant-with-google-search-grounding-36hp"&gt;a previous article&lt;/a&gt;; back then, the purpose was to answer questions. This time, I wanted to try another approach: give an existing article to the model, let it search for information outside the article on its own, and then look back to review the article.&lt;/p&gt;

&lt;p&gt;The result is a new "📄 Detailed Research Report" button on the summary card. About one to two minutes after clicking it, the Bot pushes a web link to you.&lt;/p&gt;

&lt;p&gt;Main Repo: &lt;a href="https://github.com/kkdai/linebot-helper-python" rel="noopener noreferrer"&gt;https://github.com/kkdai/linebot-helper-python&lt;/a&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  Why Grounding instead of building your own search pipeline
&lt;/h1&gt;

&lt;p&gt;Before Grounding, to let a model read real-time information from the web, you had to build a pipeline yourself: first, ask the model to extract keywords from the article, use those keywords to call a search API, crawl the search result pages one by one, stuff them into the prompt, and then ask the model to summarize. This involves three or more API calls, any of which could fail, and the quality of the extracted keywords directly determines whether the retrieved information is useful.&lt;/p&gt;

&lt;p&gt;Grounding integrates this entire process into the model. You simply attach a &lt;code&gt;google_search&lt;/code&gt; tool in the &lt;code&gt;GenerateContentConfig&lt;/code&gt;, and the model handles the rest: it decides whether to search, what to search for, how many times to search, and judges which results are worth using.&lt;/p&gt;

&lt;p&gt;For the "Research Report" topic, the model deciding what to search for is particularly valuable. When writing the prompt, I don't know what article the user will provide, so I naturally can't write the specific keywords to search. But after the model reads the article, it knows; it will look for the context of the topic and check if there are opposing views.&lt;/p&gt;

&lt;p&gt;Another advantage I care about is that &lt;strong&gt;citations are returned&lt;/strong&gt;. The &lt;code&gt;grounding_metadata&lt;/code&gt; in the model's response contains the actual web pages it referenced, including titles and URLs. This means phrases like "according to other reports" in the report aren't just the model speaking from memory; there are corresponding web pages you can click to verify. For information-based products, this makes a huge difference.&lt;/p&gt;

&lt;p&gt;The code to extract sources is in &lt;code&gt;loader/langtools.py&lt;/code&gt;, written defensively because these fields don't exist at all if no search was triggered:&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;_extract_grounding_sources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Extract citations from grounding metadata (same approach as chat_session).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;candidates&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&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;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;candidates&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="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;grounding_metadata&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&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;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;grounding_chunks&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&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;metadata&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
                &lt;span class="n"&gt;web&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&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;web&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;web&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&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="ow"&gt;or&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;uri&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;web&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;uri&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="ow"&gt;or&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;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to extract grounding sources: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&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="n"&gt;sources&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  System Architecture
&lt;/h1&gt;

&lt;p&gt;The entire flow starts from the button on the summary card, goes through a re-crawl and a grounding call, and ends with a temporary webpage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    A[User sends URL] --&amp;gt;|Summary Flex Bubble| B[📄 Detailed Research Report Button]
    B --&amp;gt;|Postback with bookmark doc id| C[Verify bookmark ownership]
    C --&amp;gt;|Immediate Reply: Researching| D[LINE Chatroom]
    C --&amp;gt;|Background Task| E[load_url: Re-crawl original text]
    E --&amp;gt; F[Gemini + Google Search Grounding]
    F --&amp;gt;|Markdown + Citations| G[render_report_page to HTML]
    G --&amp;gt;|Store in memory ReportStore| H[Get uuid report_id]
    H --&amp;gt;|Push Link| I[GET /reports/:id Temporary Webpage]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The button carries the bookmark's document ID, not the URL itself. This follows the existing "Save Bookmark" mechanism. The benefit is that using the doc ID allows verifying that the bookmark actually belongs to the user before generating the report. Conversely, if Firestore isn't connected or the doc ID can't be retrieved, this button won't appear.&lt;/p&gt;




&lt;h1&gt;
  
  
  Core Implementation
&lt;/h1&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%2Fs4005bj2s2xp47g9bpcy.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%2Fs4005bj2s2xp47g9bpcy.png" alt="Preview 2026-08-17 20.40.43" width="800" height="1734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Generating Research Reports with Grounding
&lt;/h2&gt;

&lt;p&gt;The key to &lt;code&gt;generate_research_report()&lt;/code&gt; isn't the code, but the prompt. I explicitly ask the model to search proactively and require it to label which information comes from the search and which comes from the original text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    prompt = f"""You are a rigorous research analyst. Please write a detailed research report based on the following article content,
in Traditional Chinese (Taiwan usage), Markdown format (starting from ## level, do not include the main article title).

Required Structure:
## Executive Summary (3-5 sentences explaining what this is about and why it matters)
## Background Context (The history and context of this topic, combined with relevant information you searched for)
## Core Arguments &amp;amp; Evidence (Organize the article's claims and supporting evidence point by point, labeling the strength of evidence)
## Data &amp;amp; Fact Summary (Key numbers, dates, people, and organizations from the text, using tables or lists)
## Counter-perspectives &amp;amp; Critique (Search for related reports, compare other viewpoints; point out blind spots, assumptions, or controversies in the article)
## Further Questions (3-5 questions worth investigating further)

Requirements:
- Please proactively search for supplementary background and comparative information outside the article, and label in the text whether the information comes from search or the original text.
- Be specific rather than abstract; clearly label unsupported inferences as "speculation".
- Use full-width punctuation, avoid AI-sounding clichés.

Original URL: {url}

Article Content:
{text}"""

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The phrases "label the strength of evidence" and "clearly label unsupported inferences as speculation" are the parts of the prompt I care about most. Without them, every sentence in the report would sound equally confident, and the reader wouldn't be able to distinguish what the article said, what the model added from search results, and what it inferred itself.&lt;/p&gt;

&lt;p&gt;The part for attaching the tool is very short; whether &lt;code&gt;tools&lt;/code&gt; is provided or not is the difference between having grounding or not:&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;_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_grounding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_vertex_client&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;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;google_search&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GoogleSearch&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;with_grounding&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_content&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-3.1-flash-lite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GenerateContentConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.4&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;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;max_output_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;labels&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;client_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;info_helper&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="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="k"&gt;try&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="nf"&gt;_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_grounding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Grounded research call failed, retrying without tools: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_grounding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used two layers of &lt;code&gt;try&lt;/code&gt; because grounding involves external searches, so the failure rate is naturally higher than pure text generation. When the tool call fails, instead of returning "Generation failed," it's better to retry once with the same prompt but without the tool. In this case, the user gets a pure article analysis without comparative views or sources, but at least they have something. This degradation is intentional, not an accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Where to put the report
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ly07ct6o8hjjjbmn02q.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%2F3ly07ct6o8hjjjbmn02q.png" alt="Preview 2026-08-17 20.40.35" width="800" height="1734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The report is a full Markdown document, often thousands of words long, which can't fit into a LINE message. Making it a Flex Message isn't suitable either because it contains tables and multi-level headings. So, I turned it into a webpage.&lt;/p&gt;

&lt;p&gt;But then I had to decide: should these reports be stored in a database?&lt;/p&gt;

&lt;p&gt;I chose not to. The reports are only stored in memory and disappear as soon as the Cloud Run instance is recycled:&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;class&lt;/span&gt; &lt;span class="nc"&gt;ReportStore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&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;ttl_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_REPORT_TTL_SECONDS&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;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ttl_seconds&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;_reports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;put&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;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;report_id&lt;/span&gt; &lt;span class="o"&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="nb"&gt;hex&lt;/span&gt;
        &lt;span class="k"&gt;with&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;_lock&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="nf"&gt;_purge_expired&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;_reports&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;report_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;report_id&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;report_id&lt;/code&gt; uses &lt;code&gt;uuid.uuid4().hex&lt;/code&gt; because this URL has no login protection; anyone with the link can open it, so the ID must be unguessable. The page itself also includes &lt;code&gt;&amp;lt;meta name="robots" content="noindex"&amp;gt;&lt;/code&gt; to prevent search engines from indexing people's reading history.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Expired Pages
&lt;/h2&gt;

&lt;p&gt;Since reports disappear, "link expiration" is not an exception but the normal end for every report. So the route is written like this:&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.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;/reports/{report_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;serve_research_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Temporary research report page: returns expired page (404) after expiration or instance restart.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report_store&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="n"&gt;report_id&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;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HTMLResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HTMLResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;render_expired_page&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also made it clear in the message pushed to the user, not pretending it's a permanent link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⏳ This is a temporary page, kept for about 24 hours (invalidated after the service sleeps). Please copy the content if you need to save it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Major Pitfalls and Solutions
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Grounding tools and response_schema cannot be used together
&lt;/h2&gt;

&lt;p&gt;I encountered this pitfall earlier when building a map restaurant search. At that time, I naturally thought: since I want to get a restaurant list from the model, I'll use structured output, attach &lt;code&gt;response_mime_type="application/json"&lt;/code&gt; and &lt;code&gt;response_schema&lt;/code&gt;, and get correctly typed data directly to save myself from parsing it.&lt;/p&gt;

&lt;p&gt;The result was an immediate API error. The fix then was to remove the schema (commit &lt;code&gt;a2c8745&lt;/code&gt;) and instead ask the model to output JSON text, which I then parsed myself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google Search Grounding and structured output are mutually exclusive. When the model is performing grounding, it needs to freely intersperse searching, thinking, and citing; this process cannot be simultaneously constrained to a fixed JSON schema.&lt;/p&gt;

&lt;p&gt;So when making the research report, I abandoned the idea of "returning a structured object" from the start and let the model output Markdown plain text directly, writing the structure into the "Required Structure" section of the prompt instead of the schema.&lt;/p&gt;

&lt;p&gt;Looking back, this limitation actually made things simpler. The report is meant to be a long-form text for humans to read; Markdown is its most natural form. If forced into JSON fields, it would just have to be stitched back into an article during rendering anyway. The only fields that truly need a strict structure are the citations, and those can be taken from &lt;code&gt;grounding_metadata&lt;/code&gt;, which never needed a schema to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: Three-second timeout, plus Gemini is synchronous and blocking
&lt;/h2&gt;

&lt;p&gt;LINE Webhook requires an HTTP 200 response within three seconds, but this feature needs to re-crawl the original text and then wait for the grounding call to finish, which takes one to two minutes in total.&lt;/p&gt;

&lt;p&gt;Let's talk about the part fewer people notice. &lt;code&gt;client.models.generate_content()&lt;/code&gt; is a synchronous blocking call. Even if you wrap it in an &lt;code&gt;async def&lt;/code&gt;, it will still block the entire event loop. While one user is generating a report for those two minutes, messages from other users will also be blocked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Split it into two parts: reply and push, each with its own responsibility:&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&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;url&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;line_bot_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reply_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;TextSendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔬 Starting in-depth research on this article (approx. 1-2 mins). I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ll send you the report link once finished.&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;crawled_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;load_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Gemini call is synchronous and blocking; offload to a thread to avoid blocking other tasks on the event loop
&lt;/span&gt;        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generate_research_report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crawled_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, use &lt;code&gt;reply_message&lt;/code&gt; to say "Starting research," finishing the webhook request within three seconds. The heavy lifting is moved to the background, and the synchronous Gemini call is offloaded to a thread using &lt;code&gt;asyncio.to_thread&lt;/code&gt;. Once finished, use &lt;code&gt;push_message&lt;/code&gt; to proactively send the link.&lt;/p&gt;

&lt;p&gt;The phrase "approx. 1-2 mins" is also intentional. If a user clicks a button and nothing happens for thirty seconds, they'll start to suspect it's broken and click it again. Explaining how long to wait upfront is cheaper than explaining it afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: Who can see whose report
&lt;/h2&gt;

&lt;p&gt;The button carries the bookmark's doc ID. If I simply used this ID to query data and generate a report, anyone who could construct a postback could read the content of bookmarks saved by others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason and Solution&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Verify ownership during the query. Both parameters for &lt;code&gt;get_bookmark(user_id, doc_id)&lt;/code&gt; are required. If not found, treat it as expired without telling the user "this exists but doesn't belong to you":&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;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_bookmark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;line_bot_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reply_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;TextSendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⚠️ Data has expired. Please send the URL again and try once more.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Results and Benefits
&lt;/h1&gt;

&lt;p&gt;In practice, this button changes more than just "making the summary longer."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Summaries and research reports answer different questions.&lt;/strong&gt; A summary tells you what the piece is about, suitable for quickly deciding whether to read it. A research report tells you if the piece is correct, how others view it, and which numbers are sourced. That's why I made it two layers instead of making the summary longer: the cheap layer runs every time, and the expensive layer is there for when you really want to dive deep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Citations make the report verifiable.&lt;/strong&gt; At the bottom of the report is a "📚 References" list, all from &lt;code&gt;grounding_metadata&lt;/code&gt;, which are the actual web pages the model read. If you see something in the "Counter-perspectives &amp;amp; Critique" section that differs from the original text, you can click directly to the original report. This is why I think grounding is more worthwhile than building your own search API pipeline: what you save isn't just code, but the traceability of "where did this sentence come from."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's still something to see even if search fails.&lt;/strong&gt; After degrading to pure article analysis, the report will lack background context and comparative views, but the executive summary, core arguments, and data organization sections will still be there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporary webpages save more than expected.&lt;/strong&gt; No need to set up a database, no need to write cleanup schedules, and no need to design a report list page. A dict plus a lock is all it takes, with security maintained by unguessable UUIDs. The trade-off is that links will expire, which I've made clear in the push message. Reading behavior is usually concentrated in the few minutes after receiving a link; I don't think it's worth the overhead of a full persistence system for the few cases where someone wants to save it long-term.&lt;/p&gt;

&lt;p&gt;If long-term storage is really needed later, the current architecture isn't hard to change: the &lt;code&gt;ReportStore&lt;/code&gt; interface only has &lt;code&gt;put&lt;/code&gt; and &lt;code&gt;get&lt;/code&gt; methods. Replacing it with a Firestore implementation wouldn't require any changes to the upper layers.&lt;/p&gt;

&lt;p&gt;The entire feature adds up to about five hundred lines, a third of which are tests. The code is at &lt;a href="https://github.com/kkdai/linebot-helper-python" rel="noopener noreferrer"&gt;kkdai/linebot-helper-python&lt;/a&gt;, and the design document is in &lt;code&gt;docs/superpowers/specs/2026-08-15-research-report-design.md&lt;/code&gt;. Feel free to check it out if you're interested.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>gemini</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
