<?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 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>
    <item>
      <title>[Claude Code in Practice] Rethinking Terminal Workflows: From zsh Autocomplete to Search and Diff Toolchains</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Fri, 14 Aug 2026 16:56:08 +0000</pubDate>
      <link>https://dev.to/evanlin/claude-code-in-practice-rethinking-terminal-workflows-from-zsh-autocomplete-to-search-and-diff-4jdk</link>
      <guid>https://dev.to/evanlin/claude-code-in-practice-rethinking-terminal-workflows-from-zsh-autocomplete-to-search-and-diff-4jdk</guid>
      <description>&lt;h1&gt;
  
  
  Pain Point: Starting Over with Every Command
&lt;/h1&gt;

&lt;p&gt;When using Claude Code for tasks, there have always been two minor frictions that I hadn't seriously addressed until now.&lt;/p&gt;

&lt;p&gt;The first is that the shell itself is too bare-bones: there's nothing in &lt;code&gt;~/.zshrc&lt;/code&gt; except for two lines of &lt;code&gt;PATH&lt;/code&gt;. No auto-completion, no command history. Navigating history requires pressing the up arrow repeatedly, and even then, similar commands must be manually edited. The second is when collaborating with Claude Code, some commands that are clearly read-only and have no side effects (listing files, checking versions, curling a README) require a manual "allow" click every time. This back-and-forth breaks the flow.&lt;/p&gt;

&lt;p&gt;This post records the process of tackling both issues at once: how tools were chosen, what pitfalls were encountered, and how it was eventually integrated with Claude Code's permission system.&lt;/p&gt;




&lt;h1&gt;
  
  
  Solution 1: Let Zsh Remember What You've Typed
&lt;/h1&gt;

&lt;p&gt;I haven't installed oh-my-zsh and didn't want to carry a whole framework for just two features, so I picked the two smallest, sufficient packages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;zsh-autosuggestions&lt;/strong&gt;: Suggests similar commands in gray text while typing; press &lt;code&gt;Ctrl+Space&lt;/code&gt; or &lt;code&gt;→&lt;/code&gt; to accept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;zsh-completions&lt;/strong&gt;: Enhances the coverage of tab auto-completion.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;zsh-autosuggestions zsh-completions

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

&lt;/div&gt;



&lt;p&gt;Combined with history settings, the up/down keys can filter history based on current input instead of just scrolling through everything:&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;# --- Command History Settings ---&lt;/span&gt;
&lt;span class="nv"&gt;HISTFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.zsh_history
&lt;span class="nv"&gt;HISTSIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000
&lt;span class="nv"&gt;SAVEHIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000
setopt SHARE_HISTORY &lt;span class="c"&gt;# Share history across multiple terminal windows&lt;/span&gt;
setopt HIST_IGNORE_DUPS
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_FIND_NO_DUPS
setopt INC_APPEND_HISTORY &lt;span class="c"&gt;# Write to history file immediately upon command entry&lt;/span&gt;

autoload &lt;span class="nt"&gt;-Uz&lt;/span&gt; up-line-or-beginning-search down-line-or-beginning-search
zle &lt;span class="nt"&gt;-N&lt;/span&gt; up-line-or-beginning-search
zle &lt;span class="nt"&gt;-N&lt;/span&gt; down-line-or-beginning-search
bindkey &lt;span class="s2"&gt;"^[[A"&lt;/span&gt; up-line-or-beginning-search
bindkey &lt;span class="s2"&gt;"^[[B"&lt;/span&gt; down-line-or-beginning-search

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

&lt;/div&gt;



&lt;p&gt;After installing the packages and adding the settings, theoretically, restarting the terminal should work—but it wasn't that smooth.&lt;/p&gt;




&lt;h1&gt;
  
  
  Solution 2: Add Color to Read the Terminal Faster
&lt;/h1&gt;

&lt;p&gt;Once auto-completion was set up, I added colors as well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;zsh-syntax-highlighting&lt;/strong&gt;: Real-time coloring while typing; green for valid commands, red for invalid ones.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ls -G&lt;/code&gt;: Different colors for folders, executables, and links.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep --color=auto&lt;/code&gt;: Highlights matched keywords in red.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLICOLOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LSCOLORS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;GxFxCxDxBxegedabagaced
&lt;span class="nb"&gt;alias grep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'grep --color=auto'&lt;/span&gt;

&lt;span class="nb"&gt;source&lt;/span&gt; /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

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

&lt;/div&gt;



&lt;p&gt;Handled fonts too: Ghostty's config file (&lt;code&gt;~/Library/Application Support/com.mitchellh.ghostty/config.ghostty&lt;/code&gt;) didn't specify a font size and defaulted to the system's 13. Adding &lt;code&gt;font-size = 16&lt;/code&gt; solved it.&lt;/p&gt;




&lt;h1&gt;
  
  
  Solution 3: Replace &lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;cat&lt;/code&gt;, and &lt;code&gt;cd&lt;/code&gt; with Smarter Versions
&lt;/h1&gt;

&lt;p&gt;Color and auto-completion are infrastructure; next, I added modern alternatives for three common commands. The selection criterion was simple: &lt;strong&gt;single executable, no background daemon, and no impact on startup speed.&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;Command&lt;/th&gt;
&lt;th&gt;Alternative&lt;/th&gt;
&lt;th&gt;Benefit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eza --icons&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Color, icons, tree structure (&lt;code&gt;lt&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bat --paging=never&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Syntax highlighting, line numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cd&lt;/code&gt; (helper)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;zoxide&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remembers frequent folders; &lt;code&gt;z proj&lt;/code&gt; jumps directly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I evaluated &lt;code&gt;fzf&lt;/code&gt; but didn't install it this time—my current habits don't require fuzzy searching history/files yet. I'll add it if I feel a bottleneck later.&lt;/p&gt;




&lt;h1&gt;
  
  
  Solution 4: Make File Searching and Comparison Faster for Claude Code
&lt;/h1&gt;

&lt;p&gt;The previous items were for "user experience"; this one is for "Claude Code speed." Claude Code's built-in search tool already uses ripgrep (&lt;code&gt;rg&lt;/code&gt;), and &lt;code&gt;jq&lt;/code&gt; is already installed, so these three were missing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;fd&lt;/strong&gt;: Replaces &lt;code&gt;find&lt;/code&gt;; simple syntax, fast, especially noticeable when listing files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ast-grep&lt;/strong&gt;: &lt;strong&gt;Structural&lt;/strong&gt; code search. Not just text matching, but looking at the Abstract Syntax Tree (AST). It can search for "all calls to a function where the first argument is a string," which is much more accurate than regex for large-scale refactoring or precise searches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;difftastic&lt;/strong&gt; (&lt;code&gt;difft&lt;/code&gt;): Syntax-aware diff. It recognizes when a function has been moved rather than deleted and rewritten.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;fd ast-grep difftastic git-delta

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git-delta&lt;/code&gt; is mainly for human-readable &lt;code&gt;git diff&lt;/code&gt;. It's unrelated to Claude Code, but I installed it anyway.&lt;/p&gt;




&lt;h1&gt;
  
  
  Solution 5: Add Common Read-Only Commands to Claude Code's Permission Whitelist
&lt;/h1&gt;

&lt;p&gt;A new problem emerged after installing the tools: the first time Claude Code calls these new commands, it still asks for permission. Using the &lt;code&gt;fewer-permission-prompts&lt;/code&gt; skill to scan recent session transcripts, I identified frequently run, truly read-only commands and compiled a whitelist:&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;"permissions"&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;"allow"&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;"Bash(curl -s https://raw.githubusercontent.com/*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(curl -s &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;https://api.github.com/*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(brew list*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(xcodes list*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(xcodebuild -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;"Bash(curl -sI *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(difft *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(delta *)"&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;fd&lt;/code&gt;, &lt;code&gt;rg&lt;/code&gt;, and &lt;code&gt;jq&lt;/code&gt; are not on the list, not because they were missed, but because Claude Code already includes them as built-in, auto-allowed read-only commands.&lt;/p&gt;




&lt;h1&gt;
  
  
  Three Easily Overlooked Pitfalls
&lt;/h1&gt;

&lt;h3&gt;
  
  
  Pitfall 1: &lt;code&gt;compinit&lt;/code&gt; complaining about "insecure directories"
&lt;/h3&gt;

&lt;p&gt;After installing packages and restarting the terminal, the first launch showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;zsh compinit: insecure directories, run compaudit for list.

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;compaudit&lt;/code&gt; revealed the issue was that the &lt;code&gt;/opt/homebrew/share&lt;/code&gt; directory permissions were too open (group write access). &lt;code&gt;compinit&lt;/code&gt; checks permissions before loading completion scripts; if any directory is "writable by others," it refuses to load to prevent malicious scripts from being injected into the completion path. The fix is the official Homebrew recommendation:&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="nb"&gt;chmod &lt;/span&gt;go-w /opt/homebrew/share
&lt;span class="nb"&gt;chmod&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; go-w /opt/homebrew/share/zsh
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.zcompdump&lt;span class="k"&gt;*&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Clearing the cache to let it rebuild solved the problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 2: &lt;code&gt;zsh-syntax-highlighting&lt;/code&gt; must be the last line
&lt;/h3&gt;

&lt;p&gt;The official documentation is clear: this line must be the &lt;strong&gt;last thing executed&lt;/strong&gt; in &lt;code&gt;.zshrc&lt;/code&gt;. If placed before &lt;code&gt;zsh-autosuggestions&lt;/code&gt; or other &lt;code&gt;bindkey&lt;/code&gt; settings, syntax highlighting and auto-suggestions may interfere, and key bindings might fail. I specifically moved it to the very end of the file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 3: &lt;code&gt;ast-grep&lt;/code&gt; was intentionally left out of the whitelist
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ast-grep&lt;/code&gt; is missing from the whitelist on purpose. By default, it's a read-only search, but adding the &lt;code&gt;-U&lt;/code&gt; / &lt;code&gt;--update-all&lt;/code&gt; flag allows it to rewrite files. Permission rules use prefix matching, so I can't allow only the "no &lt;code&gt;-U&lt;/code&gt;" usage. Opening a broad rule like &lt;code&gt;Bash(ast-grep *)&lt;/code&gt; theoretically allows file-modifying usage as well.&lt;/p&gt;

&lt;p&gt;This aligns with the original logic for &lt;code&gt;sed&lt;/code&gt;: only "read-only expressions" are auto-allowed; any usage with in-place editing still prompts for permission. Rather than re-evaluating the risk, I followed the same standard.&lt;/p&gt;




&lt;h1&gt;
  
  
  Summary and Benefits
&lt;/h1&gt;

&lt;p&gt;This terminal environment overhaul was essentially about optimizing "human typing" and "Claude Code execution" separately:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Less typing&lt;/strong&gt;: &lt;code&gt;zsh-autosuggestions&lt;/code&gt; + history filtering means almost no re-typing repetitive commands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster reading&lt;/strong&gt;: Syntax highlighting, &lt;code&gt;eza&lt;/code&gt;, and &lt;code&gt;bat&lt;/code&gt; make outputs instantly readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster navigation&lt;/strong&gt;: &lt;code&gt;zoxide&lt;/code&gt; replaces memorizing paths, and &lt;code&gt;fd&lt;/code&gt; replaces the slow &lt;code&gt;find&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More accurate Claude Code searches&lt;/strong&gt;: &lt;code&gt;ast-grep&lt;/code&gt; adds structural search that plain text matching can't do, and &lt;code&gt;difftastic&lt;/code&gt; makes diff results closer to actual changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer "allow" clicks&lt;/strong&gt;: Whitelisting truly read-only, risk-controlled commands while still prompting for things that should be asked (like &lt;code&gt;ast-grep -U&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The core principle for tool selection remained the same: single executable, no background daemons, and avoiding frameworks where possible. The real time-consumer was deciding "whether to whitelist this"—speed is secondary; the priority is ensuring a command that can modify files isn't accidentally wrapped in a seemingly safe, generic rule.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>cli</category>
      <category>productivity</category>
      <category>terminal</category>
    </item>
    <item>
      <title>[Dev Log][Python] Create short videos from photos and clips with Gemini 3.7 Flash: ReelCraft</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Fri, 14 Aug 2026 16:55:48 +0000</pubDate>
      <link>https://dev.to/gde/dev-logpython-create-short-videos-from-photos-and-clips-with-gemini-37-flash-reelcraft-1gc6</link>
      <guid>https://dev.to/gde/dev-logpython-create-short-videos-from-photos-and-clips-with-gemini-37-flash-reelcraft-1gc6</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%2Fgc3fc1ltzuy2awymgv6n.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%2Fgc3fc1ltzuy2awymgv6n.png" alt="reelcraft-logo" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Preface:
&lt;/h2&gt;

&lt;p&gt;It all started with a misunderstanding.&lt;/p&gt;

&lt;p&gt;I noticed a new page in the Gemini API documentation called &lt;a href="https://ai.google.dev/gemini-api/docs/omni" rel="noopener noreferrer"&gt;Omni&lt;/a&gt;, introducing a model named Gemini Omni Flash, described as "natively multimodal, processing text, images, audio, and video simultaneously." My first thought was straightforward: if I throw a whole folder of videos and photos from my phone into it, let it understand what each asset is about, and then tell it in one sentence to edit them into a short video—isn't that a video editing app?&lt;/p&gt;

&lt;p&gt;After reading the documentation, I realized I had misunderstood, and the misunderstanding happened to be at the most critical point. However, after bypassing that limitation, the rest was actually feasible. The result is &lt;a href="https://github.com/kkdai/reelcraft" rel="noopener noreferrer"&gt;ReelCraft&lt;/a&gt;: a Python CLI where you feed in a bunch of videos and photos, Gemini 3.7 Flash understands the assets one by one and provides editing suggestions. Once I confirm the edit list, &lt;code&gt;ffmpeg&lt;/code&gt; cuts it into a 9:16 vertical short video, background music is generated using Lyria 3, and subtitles are automatically burned in.&lt;/p&gt;

&lt;p&gt;Along the way, there were three issues where both &lt;code&gt;ffmpeg&lt;/code&gt; and Gemini reported success, but the output was wrong—the kind of errors you only discover by actually playing the video.&lt;/p&gt;

&lt;h1&gt;
  
  
  TL;DR
&lt;/h1&gt;

&lt;p&gt;This article will cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Omni Flash is not what I thought it was&lt;/li&gt;
&lt;li&gt;Bypassing limitations: Per-file understanding, then text aggregation&lt;/li&gt;
&lt;li&gt;Using edl.yaml as a human confirmation point&lt;/li&gt;
&lt;li&gt;The difference after switching to Gemini 3.7 Flash&lt;/li&gt;
&lt;li&gt;Background music: Lyria 3 uses a different API&lt;/li&gt;
&lt;li&gt;ffmpeg will silently fail your edits&lt;/li&gt;
&lt;li&gt;Subtitles: Two issues only visible after burning them in&lt;/li&gt;
&lt;li&gt;Other pitfalls&lt;/li&gt;
&lt;li&gt;Conclusion&lt;/li&gt;
&lt;li&gt;Reference links&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Omni Flash is not what I thought it was
&lt;/h1&gt;

&lt;p&gt;Gemini Omni Flash (&lt;code&gt;gemini-omni-flash-preview&lt;/code&gt;) is a video generation and editing model that uses the Interactions API. It allows you to use natural language to apply effects to a single video, such as "when the person touches the mirror, make the mirror ripple beautifully like liquid." It is not a tool for "understanding a bunch of videos."&lt;/p&gt;

&lt;p&gt;The limitation section states clearly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Referencing or reasoning across multiple videos is not supported. Attempting multi-video prompting may result in degraded model performance or unexpected outputs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Additionally:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Video references up to 3 seconds in duration are accepted by the API schema but are not correctly processed by the model at this time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the path of "throwing a bunch of videos in and letting it understand and edit them" was blocked for Omni Flash. The models that can actually perform multi-video understanding are the standard Gemini models: starting from version 2.5, a single request can include up to 10 videos. With a 1M context window, it can handle about an hour of footage at default resolution, tokenize it second-by-second, and output scene descriptions with timestamps.&lt;/p&gt;

&lt;p&gt;The time spent on this misunderstanding wasn't wasted. The verification process helped clarify "which task should be handled by which model," and the architecture followed naturally.&lt;/p&gt;

&lt;h1&gt;
  
  
  Bypassing limitations: Per-file understanding, then text aggregation
&lt;/h1&gt;

&lt;p&gt;The entire pipeline is split into five stages, with states stored in files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Asset Folder]
     │ poc ingest: Scan videos/photos → catalog.json
     ▼
     │ poc analyze: Call Gemini for each file individually → analysis/*.json
     ▼
     │ poc plan: Aggregate all analysis results, call once for editing suggestions
     ▼ → summary.md (for humans) + edl.yaml (for machine execution)
     ⏸ Human inspection and editing of edl.yaml
     ▼
     │ poc render: ffmpeg editing, 9:16 cropping, xfade transitions
     ▼
output/final.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key design decision is in the second and third steps: call Gemini once for each video to get precise internal timestamps and descriptions; then feed these text results (not the raw videos) into a second call for cross-asset aggregation, sequencing, and editing suggestions.&lt;/p&gt;

&lt;p&gt;This approach has two benefits. First, it completely avoids the "multi-video reasoning not supported" issue because the second call only sees text, not ten videos. Second, it isn't limited by the 10 videos/request cap; no matter how many assets there are, it just means more independent calls in the &lt;code&gt;analyze&lt;/code&gt; phase. Those calls can be retried or fail individually without affecting each other.&lt;/p&gt;

&lt;p&gt;Testing also proved that timestamps are more reliable when processed separately. When asking about ten videos in a single prompt ("which seconds are the highlights?"), the model easily confuses the timelines of different videos.&lt;/p&gt;

&lt;p&gt;Failure handling in the &lt;code&gt;analyze&lt;/code&gt; phase is recorded separately: if a file fails after three retries, it's logged in &lt;code&gt;analysis/_errors.json&lt;/code&gt;, while other files continue. This later revealed a loophole during review, which I'll discuss later.&lt;/p&gt;

&lt;h1&gt;
  
  
  Using edl.yaml as a human confirmation point
&lt;/h1&gt;

&lt;p&gt;I decided from the start not to make it "one-click fully automatic." Between inputting assets and outputting the final product, there must be a place where I can manually intervene, because LLM-provided edit points will inevitably have some irrationalities, and re-running the entire pipeline incurs API costs again.&lt;/p&gt;

&lt;p&gt;That interface is a YAML file:&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;target_duration_sec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;23&lt;/span&gt;
&lt;span class="na"&gt;aspect_ratio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;9:16'&lt;/span&gt;
&lt;span class="na"&gt;clips&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/abs/path/808327978.mp4&lt;/span&gt;
  &lt;span class="na"&gt;note: Opening shot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Showing the COSCUP x UbuCon Asia main visual backdrop.&lt;/span&gt;
  &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;00:00.000'&lt;/span&gt;
  &lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;00:02.500'&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/abs/path/S__1908753.jpg&lt;/span&gt;
  &lt;span class="na"&gt;note: Fun venue easter egg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Creative semiconductor chip snacks distributed on-site.&lt;/span&gt;
  &lt;span class="na"&gt;duration_sec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4.0&lt;/span&gt;
&lt;span class="na"&gt;transitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;crossfade 0.3s&lt;/span&gt;
&lt;span class="na"&gt;mood_tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Professional&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Joyful&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Community Cohesion&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Videos use &lt;code&gt;in&lt;/code&gt;/&lt;code&gt;out&lt;/code&gt; to mark the range, photos use &lt;code&gt;duration_sec&lt;/code&gt; for duration, and &lt;code&gt;note&lt;/code&gt; is the reason for selection written by Gemini (this field was later used for subtitles, see below). To change an edit point, just change the numbers; to change the order, move the clip; after saving, run &lt;code&gt;poc render&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The outputs of each stage remain in the project directory, so any step can be re-run individually. &lt;code&gt;analyze&lt;/code&gt; also skips files that already have analysis results, so re-running doesn't incur double charges—this is very helpful when iterating on prompts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;poc plan --theme&lt;/code&gt; was added later: you can provide a sentence as the editing theme, e.g., &lt;code&gt;--theme "Participating in the COSCUP open source community"&lt;/code&gt;. This affects the narrative angle of the summary, the priority of clip selection, and the wording of each clip's &lt;code&gt;note&lt;/code&gt;. Since it only affects the &lt;code&gt;plan&lt;/code&gt; stage, changing the theme doesn't require re-analyzing assets, making it very cheap to try different narratives on the same set of materials.&lt;/p&gt;

&lt;h1&gt;
  
  
  The difference after switching to Gemini 3.7 Flash
&lt;/h1&gt;

&lt;p&gt;The understanding and aggregation stages initially used &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, then switched to &lt;a href="https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash" rel="noopener noreferrer"&gt;&lt;code&gt;gemini-3.7-flash&lt;/code&gt;&lt;/a&gt;. This is the GA stable version, not a preview:&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;Model ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemini-3.7-flash&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input&lt;/td&gt;
&lt;td&gt;1,048,576 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;65,536 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input Types&lt;/td&gt;
&lt;td&gt;Text, Image, Video, Audio, PDF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capabilities&lt;/td&gt;
&lt;td&gt;structured outputs, function calling, caching, thinking (low/medium/high)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not Supported&lt;/td&gt;
&lt;td&gt;Video/Image/Audio generation, Live API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For this project, the most important features are structured outputs and video input, as the &lt;code&gt;analyze&lt;/code&gt; stage involves feeding in a video and requesting a JSON with a fixed schema.&lt;/p&gt;

&lt;p&gt;After switching, I didn't just change the string and call it a day; I verified it with actual API calls, running &lt;code&gt;analyze_file&lt;/code&gt; on real assets. For the same lecture video, the difference in descriptions between the two models was quite noticeable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gemini-2.5-flash&lt;/code&gt; version:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;At the start of the video, a woman on stage uses a microphone to introduce herself to the audience. The large screen behind her shows her name "Zona Wang" and her job description.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;gemini-3.7-flash&lt;/code&gt; version:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In the video, a female speaker (Zona Wang, LINE Technology Evangelist) is giving a self-introduction and presentation on a stage in a lecture hall, followed by a camera pan across the audience listening intently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The difference lies in "job description" vs. "LINE Technology Evangelist." The latter actually read the small text on the slide, while the former only knew there was some job information there.&lt;/p&gt;

&lt;p&gt;The gap in the aggregation stage was even larger. For the same set of COSCUP assets and the same &lt;code&gt;--theme&lt;/code&gt;, 2.5's summary was: "This short video aims to showcase the vitality and diversity of the COSCUP open source community. From professional knowledge sharing and deep technical exchange to warm interaction and inclusion among community members"—the whole thing stayed at an abstract level. 3.7 recognized the full event name "COSCUP x UbuCon Asia," booth names like "FOSS for All" and "Kubernetes," and even described a photo as "Creative semiconductor chip snacks distributed on-site." These details weren't in my prompt; they all came from the text and objects in the photos.&lt;/p&gt;

&lt;p&gt;For an application where "asset understanding quality directly determines editing quality," the benefit of switching models was greater than I expected. The editing suggestions improved because it actually understood more, not because the prompt was written better.&lt;/p&gt;

&lt;p&gt;By the way, 3.7's &lt;code&gt;note&lt;/code&gt; style also changed to a "Short Label: Detailed Description" format. This change later broke all my subtitles, as discussed below.&lt;/p&gt;

&lt;h1&gt;
  
  
  Background music: Lyria 3 uses a different API
&lt;/h1&gt;

&lt;p&gt;Background music is generated using &lt;a href="https://ai.google.dev/gemini-api/docs/music-generation" rel="noopener noreferrer"&gt;Lyria 3&lt;/a&gt;. There are two models: &lt;code&gt;lyria-3-clip-preview&lt;/code&gt; for 30-second clips, and &lt;code&gt;lyria-3-pro-preview&lt;/code&gt; for full songs. My output is about 20 seconds, so the clip version is perfect.&lt;/p&gt;

&lt;p&gt;It doesn't require a separate Vertex AI application or allowlisting; the same Gemini API key works. However, the calling method is completely different from &lt;code&gt;generate_content&lt;/code&gt;, using &lt;code&gt;client.interactions.create()&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="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;lyria-3-clip-preview&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;An instrumental background music track for a short social-media video, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;about 20 seconds long. Mood: Professional, Joyful, Community Cohesion, Happy. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No vocals, no lyrics, loopable.&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="n"&gt;audio_bytes&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;Several things were different from what I imagined.&lt;/p&gt;

&lt;p&gt;It has no structured parameters. Length, BPM, genre, and mood must all be written in the natural language prompt, rather than passing a field like &lt;code&gt;bpm=120&lt;/code&gt;. So the &lt;code&gt;generate_score(mood_tags, duration_sec)&lt;/code&gt; function's job is actually to concatenate mood tags and seconds into an English sentence. Mood tags are aggregated from asset analysis results during the &lt;code&gt;plan&lt;/code&gt; stage, and &lt;code&gt;poc render --mood "Happy, Joyful, Celebration"&lt;/code&gt; can further overlay desired directions.&lt;/p&gt;

&lt;p&gt;It is single-turn generation and cannot be iteratively modified. Unlike Omni Flash's video editing, once the music is generated, it's set; if you're not satisfied, you have to submit a new prompt. All generated audio includes a SynthID watermark.&lt;/p&gt;

&lt;p&gt;When the music is shorter than the video, you have to handle it yourself. The clip version is max 30 seconds, but the video might be longer. So during mixing, I use &lt;code&gt;-stream_loop -1&lt;/code&gt; to loop the audio infinitely and &lt;code&gt;-shortest&lt;/code&gt; to trim it to the video length:&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;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-stream_loop&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;-1&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;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;audio_path&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;span class="c1"&gt;# ... filter_complex, map video ...
&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-map&lt;/span&gt;&lt;span class="sh"&gt;"&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;audio_index&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:a&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;-c:a&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;aac&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;-b:a&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;128k&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;-shortest&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;Music generation failure (quota, network, safety filters) won't crash the entire render; it prints a warning and falls back to silent output. This principle was later added to the project's &lt;code&gt;CLAUDE.md&lt;/code&gt;: any value-added feature calling an external generative API must degrade gracefully and not let the main process die because of a secondary feature.&lt;/p&gt;

&lt;h1&gt;
  
  
  ffmpeg will silently fail your edits
&lt;/h1&gt;

&lt;p&gt;The render stage uses ffmpeg's &lt;code&gt;xfade&lt;/code&gt; filter to connect clips. Each &lt;code&gt;xfade&lt;/code&gt; requires an &lt;code&gt;offset&lt;/code&gt; parameter, which is "at which second in the output timeline to start this transition." The logic for accumulation is: the sum of all previous clip lengths minus the seconds overlapped by each transition.&lt;/p&gt;

&lt;p&gt;After writing the first version, unit tests were all green, and real assets produced normal videos. Then review identified two scenarios where ffmpeg returns exit code 0, but the output file is wrong.&lt;/p&gt;

&lt;p&gt;Scenario one: The transition is longer than the clip, causing the clip to be silently swallowed. For two 1-second clips with &lt;code&gt;transitions: "crossfade 2s"&lt;/code&gt;, the calculated offset is &lt;code&gt;-1.000&lt;/code&gt;. ffmpeg accepts this negative number, doesn't report an error, and finishes normally. The output is a 1-second video containing only the first clip; the second one disappears entirely. Since &lt;code&gt;EDL.transitions&lt;/code&gt; is a free-text field, it's entirely possible for me to type &lt;code&gt;3s&lt;/code&gt; instead of &lt;code&gt;0.3s&lt;/code&gt; when manually editing the YAML, and it won't tell me in any way.&lt;/p&gt;

&lt;p&gt;Scenario two: &lt;code&gt;out&lt;/code&gt; exceeds the actual asset length, causing everything following it to be truncated. For a 10-second video, if the EDL says &lt;code&gt;in: 8.0&lt;/code&gt; / &lt;code&gt;out: 15.0&lt;/code&gt;, only 2 seconds can actually be taken. If a 1.5-second photo follows, the offset is calculated as 6.700, which falls after the end of the first stream. The result is a 2-second output where the photo is completely missing, and the exit code is still 0. This scenario is even more important to prevent because the EDL is generated by an LLM, and hallucinating an out-of-bounds end time is quite natural.&lt;/p&gt;

&lt;p&gt;I added explicit checks for both: if a negative offset is calculated, a &lt;code&gt;ValueError&lt;/code&gt; is thrown specifying which clip and transition length; before rendering, &lt;code&gt;ffprobe&lt;/code&gt; is used to read the actual length of each video asset, and if &lt;code&gt;out&lt;/code&gt; exceeds it, an error is reported clearly stating the requested vs. actual duration.&lt;/p&gt;

&lt;p&gt;I care so much because a "successful" but incorrect output is much worse than a crash. If it crashes, I know to fix it immediately. With exit code 0 and a seemingly normal mp4, I might not notice until I watch the whole video and think "wait, a segment is missing," and then have no idea where to start investigating.&lt;/p&gt;

&lt;h1&gt;
  
  
  Subtitles: Two issues only visible after burning them in
&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%2F8tba5epu1gm683t5trzj.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%2F8tba5epu1gm683t5trzj.png" alt="image-20260814153330478" width="800" height="1422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The source for subtitles is the &lt;code&gt;note&lt;/code&gt; for each clip in the EDL—the editing reason written by Gemini. Since it already wrote a description for each segment, using it as an on-screen title is perfect.&lt;/p&gt;

&lt;p&gt;The implementation doesn't use &lt;code&gt;drawtext&lt;/code&gt;; instead, it generates an SRT file and burns it in using libass's &lt;code&gt;subtitles&lt;/code&gt; filter. The reason is that &lt;code&gt;drawtext&lt;/code&gt; requires manual handling of Chinese font paths and escaping characters; colons, commas, and single quotes all clash with filtergraph syntax. SRT with &lt;code&gt;force_style&lt;/code&gt; is much cleaner, and specifying &lt;code&gt;FontName=Noto Sans TC&lt;/code&gt; lets fontconfig find the Chinese font.&lt;/p&gt;

&lt;p&gt;The first issue was two subtitles appearing on screen simultaneously. In the first version, each subtitle's display interval was just the clip's own start and end times. But with a 0.3s crossfade overlap between adjacent clips, those 0.3 seconds would have two lines of white text on a black background stacked together, which looked ugly. The fix was to change each subtitle's end time to "when the next clip starts" rather than its own end time, ensuring at most one subtitle is visible at any moment. Unit tests couldn't catch this because the SRT was perfectly valid and ffmpeg burned it successfully; I only found it by looking at the frames.&lt;/p&gt;

&lt;p&gt;The second issue was subtitles all trailing with an ellipsis. The &lt;code&gt;note&lt;/code&gt; is a full sentence description, which would fill the screen if burned directly, so it's truncated into a short title: cut at the first comma or period, or use a character limit if no punctuation is found, adding "..." if truncated.&lt;/p&gt;

&lt;p&gt;After switching to Gemini 3.7 Flash, this rule fell apart. 3.7 tends to write notes in a "Opening shot: Showing the 2024 COSCUP x UbuCon Asia main visual backdrop" format—a "Short Label: Detailed Description" style. Since colons weren't in my sentence-breaking character list, the whole sentence fell into the character-limit truncation path, and all eight subtitles ended with "...".&lt;/p&gt;

&lt;p&gt;Hard truncation had a second flaw: it ignored word boundaries. "Presenting the female speaker sharing presentation content about ChatGPT and Antigravity" cut at the 20th character resulted in "...and An...", a halved English word.&lt;/p&gt;

&lt;p&gt;I fixed both: colons are now treated as label separators, and the label itself is used as the full title without an ellipsis; when hard truncation is necessary, if the cut point falls in the middle of a continuous string of English letters/numbers, it backtracks to before that string started, discarding the whole segment rather than cutting it in half. I also relaxed the character limit from 20 to 24.&lt;/p&gt;

&lt;p&gt;After re-burning, the eight subtitles became clean short titles like "Opening Shot," "Session Hall Live," "Technical Sharing Close-up," "Venue Easter Egg," and "Community Booth Interaction," without a single ellipsis.&lt;/p&gt;

&lt;h1&gt;
  
  
  Other pitfalls
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;files.upload()&lt;/code&gt; returning doesn't mean the file is ready. This was caught by digging into the SDK source code during review, and it would crash on the real API while never showing up in tests. &lt;code&gt;client.files.upload()&lt;/code&gt; returns as soon as the bytes are transferred, without waiting for server-side processing. After a video is uploaded, it stays in a &lt;code&gt;PROCESSING&lt;/code&gt; state for several seconds; trying to use it for &lt;code&gt;generate_content&lt;/code&gt; during this time results in a 400 &lt;code&gt;FAILED_PRECONDITION&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Worse, my original retry loop made things worse: &lt;code&gt;analyze_file&lt;/code&gt; was wrapped in a retry, so each retry re-uploaded the entire video and immediately failed again, with only about 3 seconds of backoff across three tries. After three tries, the asset went into &lt;code&gt;_errors.json&lt;/code&gt;, and the plan stage didn't read that file at the time, so the asset silently disappeared from the final product. The fix was adding a &lt;code&gt;wait_for_active()&lt;/code&gt;, polling &lt;code&gt;client.files.get()&lt;/code&gt; after upload until the state is &lt;code&gt;ACTIVE&lt;/code&gt; before proceeding, and moving the upload out of the retry loop.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;_errors.json&lt;/code&gt; was written but not read. As mentioned, &lt;code&gt;analyze&lt;/code&gt; diligently recorded failed assets, but &lt;code&gt;plan&lt;/code&gt; didn't read them, and &lt;code&gt;summary.md&lt;/code&gt; wouldn't mention them. The only way a user could notice was by counting the segments in the final product. Now &lt;code&gt;plan&lt;/code&gt; attaches the failure list to the end of the summary, explicitly stating which assets were not included.&lt;/p&gt;

&lt;p&gt;Re-running ingest can bite you with old analysis results. This was encountered during actual use, not review. I changed the contents of the asset folder, adding new photos and deleting old ones, then re-ran &lt;code&gt;poc ingest&lt;/code&gt;. &lt;code&gt;catalog.json&lt;/code&gt; was updated, but analysis results for deleted files were still sitting in &lt;code&gt;analysis/&lt;/code&gt;. When &lt;code&gt;plan&lt;/code&gt; read the analysis results, it didn't cross-reference them with the current catalog, so it fed outdated assets to Gemini. The model reasonably picked a segment from them, but since the file no longer existed, the whole plan failed. Now &lt;code&gt;load_analyses()&lt;/code&gt; filters by the catalog and prints which outdated records are ignored.&lt;/p&gt;

&lt;p&gt;Timestamp precision. &lt;code&gt;format_timestamp&lt;/code&gt; initially used &lt;code&gt;:04.1f&lt;/code&gt;, keeping only one decimal place. Every time an EDL went in and out of YAML, it lost up to 0.05 seconds, which is about 1.5 frames at 30fps, causing edit points to drift. I changed it to &lt;code&gt;:06.3f&lt;/code&gt; to keep millisecond precision.&lt;/p&gt;

&lt;p&gt;Looking back, these problems fall into two categories. &lt;code&gt;files.upload&lt;/code&gt; and ffmpeg silent errors were caught by reading the code line-by-line during review. Subtitle overlapping, ellipsis issues, and stale analysis results only surfaced by actually running the code, playing the videos, and trying different sets of assets. When the tests were all green, those three issues were still lurking in the code.&lt;/p&gt;

&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;What ReelCraft does now is simple: a folder of videos and photos goes in, and a 9:16 short video with music and subtitles comes out, with a YAML file in the middle that I can manually edit.&lt;/p&gt;

&lt;p&gt;Architecturally, what really makes this work is the "per-file understanding, text aggregation" split. It was conceived to bypass Omni Flash's lack of multi-video reasoning, but it ended up solving timestamp precision and asset count limits as well. After switching to Gemini 3.7 Flash, the granularity of asset understanding significantly increased, and the editing suggestions improved accordingly—the gains here were greater than what I got from tuning prompts.&lt;/p&gt;

&lt;p&gt;Two areas remain untouched: Omni Flash's single-clip generative touch-up has an empty &lt;code&gt;touch_up_clip&lt;/code&gt; interface, and subtitles are currently derived automatically from &lt;code&gt;note&lt;/code&gt;, with the &lt;code&gt;text_overlays&lt;/code&gt; field still empty. Neither music nor subtitles are cached; they are re-generated every time &lt;code&gt;render&lt;/code&gt; is run.&lt;/p&gt;

&lt;h1&gt;
  
  
  Reference Links:
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/kkdai/reelcraft" rel="noopener noreferrer"&gt;kkdai/reelcraft&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash" rel="noopener noreferrer"&gt;Gemini 3.7 Flash Model Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/gemini-api/docs/omni" rel="noopener noreferrer"&gt;Gemini Omni Flash (Video Generation and Editing)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/gemini-api/docs/video-understanding" rel="noopener noreferrer"&gt;Gemini Video Understanding&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/gemini-api/docs/music-generation" rel="noopener noreferrer"&gt;Lyria Music Generation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ffmpeg.org/ffmpeg-filters.html#xfade" rel="noopener noreferrer"&gt;ffmpeg xfade filter&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ffmpeg.org/ffmpeg-filters.html#subtitles-1" rel="noopener noreferrer"&gt;FFmpeg subtitles filter and libass&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>gemini</category>
      <category>python</category>
    </item>
    <item>
      <title>[Learning Notes][Golang] Authorization Challenges in the AI Agent Era: What is ID-JAG and Why I Re-implemented It in Go</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Tue, 28 Jul 2026 05:12:52 +0000</pubDate>
      <link>https://dev.to/gde/learning-notesgolang-authorization-challenges-in-the-ai-agent-era-what-is-id-jag-and-why-i-jfb</link>
      <guid>https://dev.to/gde/learning-notesgolang-authorization-challenges-in-the-ai-agent-era-what-is-id-jag-and-why-i-jfb</guid>
      <description>&lt;h2&gt;
  
  
  Preface:
&lt;/h2&gt;

&lt;p&gt;In the past six months or so, connecting AI Agents directly to internal systems to help get things done is no longer news. However, if you think one step further: in "whose identity" should the Agent call those APIs? If the permissions it receives are as broad as a human's, once it is tricked into performing an operation it shouldn't, the consequences could be more severe than a human's accidental slip.&lt;/p&gt;

&lt;p&gt;This is exactly the problem that ID-JAG (Identity Assertion JWT Authorization Grant) aims to solve. I recently organized the principles of this mechanism and re-implemented the MCP Server from the tutorial repo &lt;a href="https://github.com/athenz-community/id-jag-the-hard-way" rel="noopener noreferrer"&gt;&lt;code&gt;athenz-community/id-jag-the-hard-way&lt;/code&gt;&lt;/a&gt; using Go: &lt;a href="https://github.com/kkdai/id-jag-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;kkdai/id-jag-mcp&lt;/code&gt;&lt;/a&gt;. This article aims to clarify the technical principles of ID-JAG: which RFC standards it is built upon, how it differs from OAuth2 / PKCE that I've written about before, what the actual token exchange flow looks like, and finally, a demonstration of how to run and test this Go project.&lt;/p&gt;

&lt;h1&gt;
  
  
  TL;DR
&lt;/h1&gt;

&lt;p&gt;This article will introduce the following in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What is ID-JAG? Why is it needed?&lt;/li&gt;
&lt;li&gt;From OAuth2 and PKCE to new problems in the Agent era&lt;/li&gt;
&lt;li&gt;Two RFC cornerstones: Token Exchange and JWT Bearer&lt;/li&gt;
&lt;li&gt;The complete ID-JAG token exchange flow&lt;/li&gt;
&lt;li&gt;Downscoping permissions at every hop: How the Principle of Least Privilege is implemented&lt;/li&gt;
&lt;li&gt;Why re-implement this MCP Server in Go?&lt;/li&gt;
&lt;li&gt;Hands-on: Installation, Execution, and Testing&lt;/li&gt;
&lt;li&gt;Conclusion&lt;/li&gt;
&lt;li&gt;References&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  What is ID-JAG? Why is it needed?
&lt;/h1&gt;

&lt;p&gt;ID-JAG is an authorization mechanism that allows AI Agents to access protected resources on behalf of users, with the keyword being "on behalf of." Currently, it is still an IETF Internet-Draft and has not yet become an official RFC, but organizations like LY Corporation (on the Athenz authorization system) and Okta have already begun implementation, and the MCP (Model Context Protocol) specification has already cited this draft.&lt;/p&gt;

&lt;p&gt;Traditional service-to-service authorization usually falls into two extremes: either the entire service shares a universal master key (API Key, Service Account), or the user's session or long-lived token is directly lent to the program. The former has too much power, while the latter lacks an audit trail; if leaked, an attacker can almost completely impersonate the user, and it is very difficult to detect. When AI Agents start deciding which tools to call and which internal APIs to connect to on their own, the risks of both approaches are amplified: an Agent might perform operations the user never intended due to prompt injection or hallucinations. If the Agent holds a master key, the consequence is that all company data is at its mercy. In real-world scenarios, there is often a multi-layered architecture where an "Orchestrator Agent calls a Sub-Agent," and the risk is passed all the way down.&lt;/p&gt;

&lt;p&gt;What ID-JAG wants to achieve is: for every action an Agent takes, it must be able to prove that "this is a specific user, at a specific moment, authorizing me to do this specific thing," and this authorization scope should be as narrow as possible, with a validity period as short as possible. It is built on top of OAuth2 token exchange (&lt;a href="https://datatracker.ietf.org/doc/html/rfc8693" rel="noopener noreferrer"&gt;RFC 8693&lt;/a&gt;), adding a layer of proof that "this token is derived from a human's identity assertion." This is also why it is listed on &lt;a href="https://oauth.net/cross-app-access/" rel="noopener noreferrer"&gt;OAuth.net's Cross-App Access (XAA) page&lt;/a&gt;—this is precisely a new problem emerging in the Agent era.&lt;/p&gt;

&lt;p&gt;Incidentally, ID-JAG also solves a very practical user experience problem: if an Agent had to pop up a browser window for the user to manually click "Agree" every time it needed to access a new service, the experience would quickly become exhausting, leading users to just agree to everything. ID-JAG consolidates the authorization action to the moment the user logs in via SSO. After that, when the Agent needs new access permissions, it uses the already issued identity assertion to exchange for a token with the authorization server, without requiring the user to pop up and click again.&lt;/p&gt;

&lt;h1&gt;
  
  
  From OAuth2 and PKCE to new problems in the Agent era
&lt;/h1&gt;

&lt;p&gt;I previously wrote about &lt;a href="https://www.evanlin.com/go-oauth-pkce/" rel="noopener noreferrer"&gt;How to develop OAuth2 PKCE via Golang&lt;/a&gt;, which covered the implementation experience of LINE Login adopting PKCE. The problem solved in that article and the one ID-JAG solves are actually on two different levels. Comparing them makes it clearer what is new about ID-JAG.&lt;/p&gt;

&lt;p&gt;PKCE solves the problem of "whether the client identity is trustworthy": for public clients like mobile apps that cannot safely store a client secret, the authorization code might be intercepted by a malicious app on the same phone during transmission. PKCE uses a &lt;code&gt;code_verifier&lt;/code&gt; / &lt;code&gt;code_challenge&lt;/code&gt; one-time pair to ensure that even if the code is stolen, it cannot be exchanged for a token without the correct verifier. The entire problem occurs in a "single hop" between the user and the app in their hand.&lt;/p&gt;

&lt;p&gt;ID-JAG solves the problem of "whether this non-human service identity is qualified to act on behalf of this person for this task," and it often spans several hops: User logs into IdP → AI Client Gateway → MCP Server → Final Resource Server. The caller at each hop is not the user themselves, yet each must prove they are "acting under authority." The original design of OAuth 2.0 was for "human user ↔ application" scenarios and does not directly support this multi-layer Agent chain delegation scenario. PKCE protects the integrity of a single authorization exchange; ID-JAG protects the minimum necessary permissions for every link in an entire authorization chain. The two do not conflict; they are mechanisms solving problems at different stages under the same broad architecture.&lt;/p&gt;

&lt;h1&gt;
  
  
  Two RFC cornerstones: Token Exchange and JWT Bearer
&lt;/h1&gt;

&lt;p&gt;ID-JAG did not invent a new protocol out of thin air; instead, it combines two existing IETF standards. Understanding these two cornerstones is necessary to understand what the subsequent complete exchange flow is doing.&lt;/p&gt;

&lt;p&gt;The first is RFC 8693 — OAuth 2.0 Token Exchange, which defines a general protocol for "exchanging one type of token for another." Conceptually, it's like going to a currency exchange to swap Yen for Taiwan Dollars, except here you are swapping security tokens. A token exchange request looks roughly like this:&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="err"&gt;POST /token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=&amp;lt;Identity Assertion JWT&amp;gt;
subject_token_type=urn:ietf:params:oauth:token-type:jwt
requested_token_type=urn:ietf:params:oauth:token-type:access_token
scope=read:orders
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;subject_token&lt;/code&gt; contains the token representing the delegated identity, &lt;code&gt;requested_token_type&lt;/code&gt; specifies what type of token you want to exchange it for, and &lt;code&gt;scope&lt;/code&gt; can narrow down the permission range at the moment of exchange.&lt;/p&gt;

&lt;p&gt;The second is RFC 7523 — JWT Bearer Grant, which allows a JWT itself to be used directly as an OAuth 2.0 authorization credential without having to go through an authorization code exchange first:&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="err"&gt;POST /token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=&amp;lt;Signed Identity Assertion JWT&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After receiving it, the authorization server verifies the signature of this JWT using the issuer's (IdP) public key. If fields like &lt;code&gt;audience&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, and &lt;code&gt;exp&lt;/code&gt; are reasonable, it can directly issue an Access Token without secondary user consent—because the IdP has already endorsed this identity assertion.&lt;/p&gt;

&lt;p&gt;The "Identity Assertion JWT" to be exchanged usually contains the following fields:&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;"iss"&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://enterprise.idp.example.com/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice@company.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&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://api.service.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1773839486&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1773825086&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123-7dc6-42ab-b326-uniqueid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read:orders write:tickets"&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;sub&lt;/code&gt; is the user being proxied, &lt;code&gt;aud&lt;/code&gt; is which service this assertion is intended for, &lt;code&gt;scope&lt;/code&gt; is the allowed permission range, and &lt;code&gt;jti&lt;/code&gt; is a unique ID used to prevent the same assertion from being replayed. ID-JAG uses this short-lived identity assertion and, through the exchange mechanisms defined in the two RFCs above, gradually exchanges it for the Access Token that the Agent can actually use to call the API.&lt;/p&gt;

&lt;h1&gt;
  
  
  The complete ID-JAG token exchange flow
&lt;/h1&gt;

&lt;p&gt;Using the architecture of the &lt;code&gt;id-jag-the-hard-way&lt;/code&gt; tutorial repo as an example (using Athenz as the authorization server), the complete chain connects the previous two RFCs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User logs into IdP (Keycloak)
   │ Obtains OIDC ID Token
   ▼
AI Client Gateway
   │ Uses RFC 8693 Token Exchange to swap ID Token for ID-JAG
   │ (grant_type=token-exchange, subject_token_type=id_token,
   │ requested_token_type=id-jag)
   ▼
Then uses RFC 7523 JWT Bearer to swap ID-JAG for a usable Athenz Access Token
   │ (grant_type=jwt-bearer, assertion=&amp;lt;ID-JAG&amp;gt;)
   ▼
AI Client calls MCP Server with this Access Token
   │
   ▼
After receiving the request, the MCP Server performs an RFC 8693 Token Exchange "itself"
   │ Swaps the received Access Token for a new Access Token with the "minimum scope needed for this tool"
   │ (This step uses the MCP Server's own mTLS service identity, not the user's credentials)
   ▼
Calls the final Resource Server with the downscoped Access Token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Throughout the chain, the token is exchanged more than once; it is exchanged every time it crosses a trust boundary, and the scope becomes narrower with each exchange. This design intentionally ensures that the "credential" for each segment of the path is different—the token held by the AI Client Gateway cannot be used directly to trick the final Resource Server because the MCP Server stage forces re-verification and re-issuance. The issued Access Token usually records both &lt;code&gt;sub&lt;/code&gt; (the proxied user) and &lt;code&gt;act&lt;/code&gt; (the identity of the Agent actually performing the operation), so downstream services can clearly see that "Alice performed this operation through a certain Agent," and the audit trail is not broken midway.&lt;/p&gt;

&lt;h1&gt;
  
  
  Downscoping permissions at every hop: How the Principle of Least Privilege is implemented
&lt;/h1&gt;

&lt;p&gt;This is what I find to be the most beautiful part of the entire architectural design: least privilege is not just a principle written in a document; it is physically enforced by the token exchange mechanism.&lt;/p&gt;

&lt;p&gt;Taking my re-implemented &lt;code&gt;id-jag-mcp&lt;/code&gt; as an example, it provides three tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Corresponding Athenz Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_k8s_docs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api:role.docs-getter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_k8s_doc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api:role.docs-deleter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;post_k8s_doc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api:role.docs-poster&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When the MCP Server receives a request, it does not directly forward the Access Token sent by the AI Client to the upstream API—it will always use its own mTLS certificate to perform a token exchange with Athenz ZTS for the "scope actually needed by this tool." Only the newly exchanged token will be used to call the upstream. Even if the token scope at the AI Client Gateway is broader (e.g., possessing both read and delete permissions), the MCP Server will only request the specific small piece of permission actually needed for each tool before forwarding.&lt;/p&gt;

&lt;p&gt;In other words, no part of the entire system "happens" to have more power than its current task requires—this is not checked via code review or internal regulations, but is architecturally impossible to bypass.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why re-implement this MCP Server in Go?
&lt;/h1&gt;

&lt;p&gt;The original MCP Server in &lt;code&gt;id-jag-the-hard-way&lt;/code&gt; (&lt;code&gt;api_server/mcp/&lt;/code&gt;) was written in TypeScript + Express and used a hand-coded JSON-RPC 2.0 protocol (without the official SDK). I wanted to confirm two things: first, if this token exchange architecture is implemented in a different language with a different MCP SDK, can the logic truly be replicated; second, how the official &lt;a href="https://github.com/modelcontextprotocol/go-sdk" rel="noopener noreferrer"&gt;&lt;code&gt;modelcontextprotocol/go-sdk&lt;/code&gt;&lt;/a&gt; actually performs.&lt;/p&gt;

&lt;p&gt;The final implementation maintains the original core logic (same scope mapping, same mTLS token exchange flow) but replaces the protocol layer entirely with the official Go SDK. The mTLS client was custom-built (without depending on Athenz's official Go client library).&lt;/p&gt;

&lt;h1&gt;
  
  
  Hands-on: Installation, Execution, and Testing
&lt;/h1&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/kkdai/id-jag-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;kkdai/id-jag-mcp&lt;/code&gt;&lt;/a&gt; (Apache 2.0 license). The project structure and separation of responsibilities are roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cmd/id-jag-mcp/ Entry point: reads settings, assembles all components, starts HTTP server
internal/config/ Environment variable configuration reading
internal/athenz/ mTLS client + Athenz ZTS RFC 8693 token exchange
internal/tools/ Tool input types + shared upstream forwarding logic
internal/server/ MCP tool registration (official SDK) + REST shortcut routes + logging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, clone the project and build the binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kkdai/id-jag-mcp.git
&lt;span class="nb"&gt;cd &lt;/span&gt;id-jag-mcp
go build &lt;span class="nt"&gt;-o&lt;/span&gt; id-jag-mcp ./cmd/id-jag-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To actually run it, you need to prepare mTLS certificates, a reachable Athenz ZTS, and an upstream API server. Configuration is done entirely through environment variables:&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="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; certs
&lt;span class="nb"&gt;cp&lt;/span&gt; /path/to/api-mcp.crt /path/to/api-mcp.key /path/to/ca.crt certs/

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;UPSTREAM_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:14443
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AUTHORIZATION_SERVER_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://athenz-zts-server.athenz:4443/zts/v1

go run ./cmd/id-jag-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After startup, in addition to the &lt;code&gt;/mcp&lt;/code&gt; endpoint for MCP client connections, it also provides REST shortcut routes corresponding to the three tools, making it easy to test directly with &lt;code&gt;curl&lt;/code&gt; without an MCP client:&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;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$AT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://localhost:8101/api/docs

curl &lt;span class="nt"&gt;-X&lt;/span&gt; DELETE &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$AT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://localhost:8101/api/docs/5

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$AT&lt;/span&gt;&lt;span class="s2"&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;'{"name":"doc1","content":"hello"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://localhost:8101/api/docs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you just want to confirm if the logic itself is written correctly, you don't need to actually set up an Athenz/Keycloak environment—testing uses &lt;code&gt;httptest&lt;/code&gt; to simulate ZTS and the upstream API throughout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go build ./...
go vet ./...
go &lt;span class="nb"&gt;test&lt;/span&gt; ./...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, the tests for &lt;code&gt;internal/athenz&lt;/code&gt; will start a fake ZTS server to verify if the outgoing &lt;code&gt;grant_type&lt;/code&gt;, &lt;code&gt;subject_token&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, and &lt;code&gt;audience&lt;/code&gt; parameters are correct; the tests for &lt;code&gt;internal/tools&lt;/code&gt; verify that when each tool forwards to the upstream, it carries the "downscoped token after exchange" rather than the original one received. This way, you can confirm the entire token exchange logic is correct without actually connecting to Athenz.&lt;/p&gt;

&lt;p&gt;The README (available in both Chinese and English) contains a complete list of environment variables and more details.&lt;/p&gt;

&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;ID-JAG does not solve "whether this client is who it says it is" (that is a PKCE problem), but rather "whether this non-human service identity is qualified to represent a specific person to do this specific thing right now." What supports this architecture is not documentation constraints, but the chaining of RFC 8693 and RFC 7523 standards, ensuring every hop is forced to re-verify and re-downscope permissions.&lt;/p&gt;

&lt;p&gt;If your AI Agents have started interacting with internal systems, this is an architecture worth taking the time to understand—and you don't necessarily have to copy Athenz's implementation. The key is to understand the core principle that "every hop must re-issue, and the scope must get narrower," and apply it to your own authorization server.&lt;/p&gt;

&lt;h1&gt;
  
  
  Related Articles:
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.evanlin.com/go-oauth-pkce/" rel="noopener noreferrer"&gt;How to develop OAuth2 PKCE via Golang - Using LINE Login as an example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/athenz-community/id-jag-the-hard-way" rel="noopener noreferrer"&gt;athenz-community/id-jag-the-hard-way&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kkdai/id-jag-mcp" rel="noopener noreferrer"&gt;kkdai/id-jag-mcp&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://oauth.net/cross-app-access/" rel="noopener noreferrer"&gt;OAuth.net - Cross-App Access (XAA)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc8693" rel="noopener noreferrer"&gt;RFC 8693 - OAuth 2.0 Token Exchange&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7523" rel="noopener noreferrer"&gt;RFC 7523 - JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.ietf.org/html/rfc7636" rel="noopener noreferrer"&gt;RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/modelcontextprotocol/go-sdk" rel="noopener noreferrer"&gt;modelcontextprotocol/go-sdk&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://athenz.github.io/athenz/" rel="noopener noreferrer"&gt;Athenz Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/" rel="noopener noreferrer"&gt;ID-JAG IETF Internet-Draft&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>go</category>
      <category>security</category>
    </item>
    <item>
      <title>[Dev Log][Node.js] Feedly Classic is gone, so I built my own FeedFlow (Part 1)</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Tue, 28 Jul 2026 05:12:29 +0000</pubDate>
      <link>https://dev.to/gde/dev-lognodejs-feedly-classic-is-gone-so-i-built-my-own-feedflow-part-1-55h4</link>
      <guid>https://dev.to/gde/dev-lognodejs-feedly-classic-is-gone-so-i-built-my-own-feedflow-part-1-55h4</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%2Fu3pxw201xrmfpyfubtpg.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%2Fu3pxw201xrmfpyfubtpg.png" alt="image-20260726142208046" width="800" height="1734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction:
&lt;/h2&gt;

&lt;p&gt;Since Feedly's redesign, I haven't been able to get used to the interface. I especially miss the high-density, single-column, no-nonsense reading rhythm of the old Feedly Classic. My subscription sources include many English, Japanese, and Korean technical blogs. Every time I encountered them, I either had to open a new tab for translation or simply skip them. Over time, these sources became "read but ignored."&lt;/p&gt;

&lt;p&gt;Instead of continuing to settle, I spent a weekend building my own RSS reader: &lt;a href="https://github.com/kkdai/rss-feed-class-webapp" rel="noopener noreferrer"&gt;FeedFlow&lt;/a&gt;. It features a mobile-first design, dark theme, and multi-column view modes—a tribute to Feedly Classic. Data is stored in Google Firestore, and accounts are tied to LINE Login. For non-Chinese articles, the background automatically uses Gemini 2.5 Flash to translate them into Traditional Chinese. It is currently deployed on &lt;a href="https://feedflow-660825558664.asia-east1.run.app" rel="noopener noreferrer"&gt;Cloud Run&lt;/a&gt;, and I use it every day.&lt;/p&gt;

&lt;p&gt;This repo will continue to be developed. This post is the first in a series, documenting the skeleton of the entire project and the context behind several key decisions.&lt;/p&gt;

&lt;h1&gt;
  
  
  TL;DR
&lt;/h1&gt;

&lt;p&gt;This article will introduce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why build an RSS reader from scratch&lt;/li&gt;
&lt;li&gt;The significance of LINE Login in this project&lt;/li&gt;
&lt;li&gt;The RSS reader development process: From standalone MVP to multi-user cloud sync&lt;/li&gt;
&lt;li&gt;Why Gemini was chosen as the translation engine and the pitfalls encountered&lt;/li&gt;
&lt;li&gt;Frontend interface: Four view modes and mobile-first design&lt;/li&gt;
&lt;li&gt;An unexpected interlude: Cleaning leaked keys from git history&lt;/li&gt;
&lt;li&gt;Current progress and the direction of the next post&lt;/li&gt;
&lt;li&gt;Conclusion&lt;/li&gt;
&lt;li&gt;Reference links&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Why build an RSS reader from scratch
&lt;/h1&gt;

&lt;p&gt;There is no shortage of RSS readers on the market, but what I wanted was very specific: it needs to be fast on mobile, have high information density (no big images taking up the whole screen for every article), and allow for in-place translation of foreign sources without switching tabs. These three conditions combined are not fully met by any existing service, especially "automatic translation of non-Chinese articles," which is almost never treated as a first-class citizen in other readers.&lt;/p&gt;

&lt;p&gt;The project is named FeedFlow, and its core consists of three parts: an Express backend (&lt;code&gt;server.js&lt;/code&gt;) responsible for fetching RSS, parsing content, calling Gemini, and reading/writing to Firestore; a frontend using pure Vanilla JS ES Modules (&lt;code&gt;app.js&lt;/code&gt;, &lt;code&gt;store.js&lt;/code&gt;, &lt;code&gt;api.js&lt;/code&gt;, &lt;code&gt;i18n.js&lt;/code&gt;) without any frameworks; and Firestore for the database with LINE Login for accounts. The first MVP version produced subscription management, folder categorization, four view modes, and a dark theme. Every subsequent version has been built upon this skeleton.&lt;/p&gt;

&lt;h1&gt;
  
  
  The significance of LINE Login in this project
&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%2F289nqd5vrvzwrtah0alw.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%2F289nqd5vrvzwrtah0alw.png" alt="image-20260726142225462" width="800" height="1734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I previously wrote about &lt;a href="https://www.evanlin.com/go-oauth-pkce/" rel="noopener noreferrer"&gt;How to develop OAuth2 PKCE via Golang&lt;/a&gt;, which discussed the implementation details of introducing PKCE to LINE Login. At that time, LINE Login was the subject of research for deconstructing the protocol. In FeedFlow, LINE Login takes on a different role—it is the key to making the multi-user architecture viable.&lt;/p&gt;

&lt;p&gt;FeedFlow initially didn't have an account system; data was stored in the browser's localStorage. If you switched devices, your subscriptions would disappear. To achieve cloud sync, the first step was to have a stable user identity that could serve as a key for document paths in Firestore (&lt;code&gt;users/{userId}/...&lt;/code&gt;). Instead of building a custom username/password system, using the LINE User ID (&lt;code&gt;sub&lt;/code&gt; claim) obtained via LINE Login as this key saved me from building the entire password, verification email, and "forgot password" flow. For a personal project, this was much more cost-effective.&lt;/p&gt;

&lt;p&gt;This decision wasn't made all at once. The earliest version was a "lazy" version: letting users paste a LINE UID string, which the backend just used as a Firestore key without any authentication. This kind of "login" meant anyone could impersonate any UID. The next version switched to formal LINE OpenID Connect: a standard OAuth 2.1 authorization code flow. After obtaining the &lt;code&gt;id_token&lt;/code&gt;, the signature is verified using LINE's &lt;code&gt;/oauth2/v2.1/verify&lt;/code&gt;, and the session is stored in an HTTP-only cookie rather than being passed around in URL parameters. Later, &lt;code&gt;state&lt;/code&gt; and &lt;code&gt;nonce&lt;/code&gt; checks were added to block CSRF. This "get it working first, then get it right" sequence reflects the typical rhythm of personal projects: get the features working to confirm the direction, and fix security holes once the need is visible.&lt;/p&gt;

&lt;p&gt;Since the primary use case is sharing links within LINE and opening them in the LINE in-app browser, I also integrated the LIFF SDK. This allows users opening the app within LINE to log in directly using LIFF's SSO without being redirected to an external browser.&lt;/p&gt;

&lt;h1&gt;
  
  
  The RSS reader development process: From standalone MVP to multi-user cloud sync
&lt;/h1&gt;

&lt;p&gt;The features during the MVP stage weren't much different from a standard RSS reader: paste a URL, the backend parses the feed using &lt;code&gt;rss-parser&lt;/code&gt;, and if no feed is found, it uses &lt;code&gt;cheerio&lt;/code&gt; to scan the webpage's &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags for auto-discovery. Subscribed sources can be categorized into folders; articles can be marked as read or "mark all as read"; and a refresh function pulls the latest articles. In this version, all data was still stored in localStorage.&lt;/p&gt;

&lt;p&gt;What truly made the project "feel like a product" were the versions after connecting to Firestore. Each user's subscription list, folders, reading progress, and preferences are written to independent paths under &lt;code&gt;users/{LINE_UID}/...&lt;/code&gt;, ensuring multi-tenant data isolation. Reading progress is tracked in detail: not just a list of "read article" IDs (&lt;code&gt;readArticleIds&lt;/code&gt;), but also which article was last read for each feed (&lt;code&gt;lastReadArticleId&lt;/code&gt;), allowing users to pick up where they left off across devices.&lt;/p&gt;

&lt;p&gt;An interesting small mechanism is "auto-hydration": when Cloud Run redeploys or a user logs in on a new device, there is no article data in memory, only the subscription list in Firestore. At this point, the backend re-fetches and parses each feed in the subscription list in the background to populate the screen. Users don't see an empty "please subscribe first" screen; the transition is quite natural.&lt;/p&gt;

&lt;p&gt;Later, a "rich preview" version was added: when an RSS URL is pasted, in addition to fetching the feed title and description, it also grabs the latest three articles as samples. Non-Chinese content is sent to Gemini for translation, so users can understand what the source is about before actually subscribing.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why Gemini was chosen as the translation engine and the pitfalls encountered
&lt;/h1&gt;

&lt;p&gt;Translation was necessary because the subscription list contains many non-Chinese sources. The logic is simple: the backend detects the article language; if it's not Traditional Chinese, it's sent to Gemini 2.5 Flash, which returns a JSON structure with &lt;code&gt;translatedTitle&lt;/code&gt; / &lt;code&gt;translatedContent&lt;/code&gt;. The frontend adds a "✨ Trad-Ch" translation badge to article cards and the reader, and the reader includes a button to toggle between the original text and the translation. Choosing Gemini was straightforward: the latency and cost of 2.5 Flash are suitable for this "translate several articles upon entering the screen" usage. Other APIs could do it, but I was already using this GCP project, so it was easy to integrate.&lt;/p&gt;

&lt;p&gt;The first version was the most direct: using &lt;code&gt;GEMINI_API_KEY&lt;/code&gt; to call the Generative Language API. It worked, but after deploying to Cloud Run, it meant managing an extra set of API Key environment variables, increasing the risk of key leakage. Later, I changed the translation part to use Vertex AI, authenticating with the Cloud Run service's own identity (ADC, Application Default Credentials). This eliminated the need for a separate API Key—the Cloud Run service account itself has permission to call Vertex AI; you just need to set the IAM permissions. When the ADC environment is unavailable during local development, it falls back to &lt;code&gt;GEMINI_API_KEY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After this version went live, a whole batch of translation requests failed in the Cloud Run logs with the message: "Neither Vertex AI ADC self-identity nor GEMINI_API_KEY is available," even though both paths were configured. Upon investigation, I found I had misused the &lt;code&gt;@google/genai&lt;/code&gt; SDK: it requires a boolean &lt;code&gt;vertexai: true&lt;/code&gt; along with &lt;code&gt;project&lt;/code&gt; and &lt;code&gt;location&lt;/code&gt; as parallel parameters. I had initially written it as a nested &lt;code&gt;vertexai: { project, location }&lt;/code&gt;. The SDK's logic for "is Vertex mode on" and "did it read project/location" were separate; consequently, the SDK thought Vertex mode was on but couldn't find the project or region, causing every call to fail at the start.&lt;/p&gt;

&lt;p&gt;Instead of wrestling with the SDK's parameter rules, I eventually removed the &lt;code&gt;@google/genai&lt;/code&gt; package entirely. I used &lt;code&gt;google-auth-library&lt;/code&gt;'s &lt;code&gt;GoogleAuth&lt;/code&gt; to directly request an ADC token and constructed the HTTP request to the Vertex AI &lt;code&gt;generateContent&lt;/code&gt; REST endpoint myself. With one less layer of SDK abstraction, the behavior became much more predictable. In cases like this, bypassing the SDK and hitting the REST API directly is often easier than digging through documentation to find which parameters should be nested or parallel.&lt;/p&gt;

&lt;h1&gt;
  
  
  Frontend interface: Four view modes and mobile-first design
&lt;/h1&gt;

&lt;p&gt;The reference point for the interface design was Feedly Classic: a view mode toggle button in the top right, with four modes applying different layouts to the same article data without re-fetching.&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;Features&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Magazine&lt;/td&gt;
&lt;td&gt;Default mode, summary cards with images and text, suitable for quickly scanning titles and snippets.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List&lt;/td&gt;
&lt;td&gt;High-density text-only list, showing the maximum number of articles at once.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Title Only&lt;/td&gt;
&lt;td&gt;Only titles are kept, allowing for the fastest scrolling speed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cards&lt;/td&gt;
&lt;td&gt;Visual cards focused on large images, suitable for sources with rich visual content.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The sidebar uses a folder structure, listing each folder and uncategorized subscriptions with unread count badges. The article list and the reader are two separate panels; clicking an article slides it from the list into the reader, and a swipe-up gesture slides it back to the list. The operation logic follows mobile native app habits rather than the typical web behavior of full-page jumps. The overall theme is dark. The top bar contains icons for the menu, view modes, mark all as read, refresh, and settings. After logging in, the LINE display name appears where the "LINE Login" button used to be.&lt;/p&gt;

&lt;p&gt;In the settings page, "Interface Language" and "Translation Target Language" are split into two independent options. The interface currently supports zh-TW / en / ja. The translation target language determines which language Gemini translates foreign text into. There's no reason to tie these together; a user might want an English interface but still want to translate Japanese articles into Traditional Chinese.&lt;/p&gt;

&lt;h1&gt;
  
  
  Current progress and the direction of the next post
&lt;/h1&gt;

&lt;p&gt;As of writing this, two new documents have been added to the repo (&lt;code&gt;docs/superpowers/specs/&lt;/code&gt; and &lt;code&gt;docs/superpowers/plans/&lt;/code&gt;) to plan for paginated browsing of the article list—five articles per page, supporting swipe gestures, mouse wheels, and buttons for navigation. This part is still under development and will be the subject of the next post in this series.&lt;/p&gt;

&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;FeedFlow currently solves a simple problem: quickly scanning a bunch of foreign technical articles on mobile without switching tabs for translation or enduring a bloated interface. Three decisions support this architecture: LINE Login provides user identity without a custom account system, Firestore enables multi-user cloud sync, and Gemini 2.5 Flash (via Vertex AI ADC, without storing API Keys locally) handles translation. None of these were perfect on the first try—LINE Login evolved from string pasting to formal OAuth, and translation moved from raw API Keys to service identity authentication. It was all about getting it running first and then strengthening it.&lt;/p&gt;

&lt;h1&gt;
  
  
  Reference Links:
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.evanlin.com/go-oauth-pkce/" rel="noopener noreferrer"&gt;How to develop OAuth2 PKCE via Golang - Using LINE Login as an example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kkdai/rss-feed-class-webapp" rel="noopener noreferrer"&gt;kkdai/rss-feed-class-webapp&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://feedflow-660825558664.asia-east1.run.app" rel="noopener noreferrer"&gt;FeedFlow Online Demo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.line.biz/en/docs/line-login/" rel="noopener noreferrer"&gt;LINE Login Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/" rel="noopener noreferrer"&gt;Gemini API Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/docs/authentication/application-default-credentials" rel="noopener noreferrer"&gt;Vertex AI - Application Default Credentials&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
    <item>
      <title>[Book Sharing] Tsaisang’s Tales of the Strange: Japanese Mythology, Ghost Stories, and Sometimes Taiwan</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Thu, 23 Jul 2026 13:25:47 +0000</pubDate>
      <link>https://dev.to/evanlin/book-sharing-tsaisangs-tales-of-the-strange-japanese-mythology-ghost-stories-and-sometimes-2g0i</link>
      <guid>https://dev.to/evanlin/book-sharing-tsaisangs-tales-of-the-strange-japanese-mythology-ghost-stories-and-sometimes-2g0i</guid>
      <description>&lt;p&gt;&lt;a href="https://moo.im/a/egjpDI" rel="noopener noreferrer"&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%2Fss8w63nmadu83mspf0ls.jpg" width="210" height="295"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tsai-sang Talks About the Strange
Japanese Myths and Spiritual Ghost Stories, and Sometimes Taiwan
Rated by 73 people
Author: Tsai Yi-chu  Publisher: Eurasian Publishing Group
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Recommended links to buy the book:
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Readmoo: &lt;a href="https://moo.im/a/egjpDI" rel="noopener noreferrer"&gt;Buy here&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Foreword:
&lt;/h1&gt;

&lt;p&gt;This is the first book I finished reading in 2026. I didn't write any book reviews in the first half of this year because I only read a little bit of many books. I spent quite a long time reading this one as well; I happened to see it while looking for books, and it turned out the second half of the book was quite captivating, so I finished it all in one go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Synopsis
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Not just crazy, but crazier! Tsai Yi-chu a.k.a. the Chuunibyou Professor of Folklore unleashes a flood of ghost stories!

◆ How do Japan's first generation of gods play out soap opera dramas every day?
◆ How does Japanese mythology hide various "sexual metaphors" in its stories?
◆ Yokai aren't all troublemakers; which ones can make you rich or send you to heaven?
◆ Is there bullying in the world of Yokai? Can just getting old and ugly make you a Yokai?
◆ Is the Jade Emperor actually not a CEO? Is Guanyin Bodhisattva actually a foreigner?
◆ Can Taiwan also have "Shigong (Priest) Watches" and "Yokai Pokémon"?

Taiwanese people fear ghosts, Japanese people fear ghosts, people all over the world fear ghosts...
It doesn't matter if you haven't seen "Ghost Stories," come here to listen to Tsai-sang talk nonsense and discuss gods and ghosts, so you won't feel "creepy" anymore!

Most people's impression of Japan is—endless temples to visit, super cute and otaku cosplayers, AV actresses... Hey! There must also be supernatural stories, Sadako, Yokai, and various urban legends!

Listen to how Tsai-sang combines hair-raising ghost encounters with historical stories passed down through the ages, and see how he uses super "grounded" slang to reveal the cultural meanings behind Japanese mythology!

Japanese Folklore PhD Tsai Yi-chu has gathered years of research on folklore, using myths and ghost stories as a medium and easy-to-understand "netizen" language to lead readers into Japan's "Gods and Monsters." This includes the genealogy of Japanese gods, the connection between Yokai and culture, and the hidden meanings within. At the same time, he allows Taiwan's various gods to actively participate in the text through a Taiwan-Japan friendly crossover, making you understand ghost talk and become obsessed with gods and ghosts! After reading, I guarantee your mom will ask why you are reading this book on your knees!

Because "Tsai-sang Talks About the Strange" will make you shout on your knees: "What on earth was Japanese mythology on? I want some of that too!"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This book isn't one of those stiff academic papers; it's a cultural analysis book where Tsai Yi-chu (Tsai-sang), a PhD in Folklore from the University of Tsukuba, uses super "grounded" netizen slang and a hilarious style to strip down Japanese mythology and spiritual ghost stories for you! The most brilliant part is that he doesn't just talk about Japan; he occasionally pulls back to Taiwan's folk perspective for comparison.&lt;/p&gt;

&lt;p&gt;Here are the three core sections and key points of this book refined for you:&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary of the Three Core Sections
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Section Category&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Core Research Focus&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Tsai-sang's "Taiwanese-style Plain Language Interpretation" and Highlights&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1. Japanese Mythological Prototypes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The birth of Japan's first generation of gods (Izanagi, Izanami, Amaterasu, Susanoo) and the belief in vengeful spirits in history.&lt;/td&gt;
&lt;td&gt;Uses "Sex and violence, gore and SOD collections" to roast the absurd plots of Japanese mythology. Introduces the "Vengeful Spirit Fan Club" of ancient Japanese history and endemic species like Tengu.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2. Yokai and Urban Legends&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;How rural ghosts and monsters evolved into modern urban legends over time (e.g., Slit-Mouthed Woman, Super High-Speed Granny).&lt;/td&gt;
&lt;td&gt;Yokai are "new pets of urbanization," reflecting the collective anxiety and loneliness of modern people, and the media's role in fueling the supernatural trend.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3. And Sometimes Taiwan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cross-sea interactions and cultural comparisons of beliefs between Taiwan and Japan (e.g., Nagasaki Mazu and Tainan's General Flying Tiger).&lt;/td&gt;
&lt;td&gt;Demonstrates a "Taiwan-Japan Friendly Crossover." Reflects on Taiwanese people's own cultural roots and subjectivity through the lens of Japanese ghost stories.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  I. Japanese Mythology: A First Family More Dramatic Than a Soap Opera
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Love and Hate of the First Family&lt;/strong&gt;: The fallout process of Japan's creator gods (Izanagi and Izanami) is absurd and horrifying (the wife turns into a rotting corpse in Yomi, the husband is so scared he flees and divorces). Their descendants, the Sun Goddess Amaterasu and her brother Susanoo, also have a love-hate relationship. Tsai-sang jokingly says that from a modern perspective, these plots are simply a collection of various horror and gore scenarios.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vengeful Spirit Fan Club&lt;/strong&gt;: Many high-ranking gods worshipped in Japanese history (such as Sugawara no Michizane, the God of Learning, and Emperor Sutoku) were actually "victims of political struggles who died miserable deaths." Because later generations feared they would become vengeful spirits and seek revenge, they quickly built shrines to worship them as gods, forming Japan's unique culture of vengeful spirit worship.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  II. Yokai and Urban Legends: Collective Anxiety of Modern People
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yokai are the City's New Pets&lt;/strong&gt;: Former Yokai (like Kappa and Yama-uba) lived deep in the mountains and forests, representing human awe of nature. After urbanization, Yokai also "moved into the city," evolving into urban legends like the Slit-Mouthed Woman and the Super High-Speed Granny.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reflecting the Loneliness of Real Society&lt;/strong&gt;: The birth of these modern legends appears to be horror stories on the surface, but at their core, they reflect the alienation and collective anxiety of urbanites. At the same time, the book reviews the "rise and fall of the supernatural craze" fueled by Japanese mass media (TV supernatural programs) for ratings in the 80s and 90s.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  III. And Sometimes Taiwan: The Mysterious Connection Between Taiwanese and Japanese Gods and Ghosts
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Japanese-speaking Mazu and Japanese Gods&lt;/strong&gt;: The book specifically mentions the intertwining of Taiwanese and Japanese beliefs. For example, there are several Mazu temples in Nagasaki, Japan, where Mazu "speaks Japanese" due to localization. Meanwhile, in Tainan, Taiwan, there is the "General Flying Tiger Temple," which enshrines Shigeo Sugiura, a Japanese pilot who sacrificed himself to protect Taiwanese villagers during WWII.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Folklore is the "Study of People"&lt;/strong&gt;: Tsai-sang emphasizes that whether studying Japanese mythology or Taiwanese supernatural phenomena, what is ultimately terrifying or absurd is not the ghosts and monsters, but the human society behind them. Belief can soothe the soul because it reflects the logic of contemporary thinking.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Core Quote of the Book:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"To understand people is to understand ghosts; Yokai and spirits are all imaginations based on reality."&lt;/p&gt;

&lt;p&gt;We must discover the main reasons why each phenomenon forms. When we use Japanese ghost stories as a mirror to deeply understand the workings of folklore and legends, we can then look back with clearer eyes to discover and identify the cultural identity that belongs to "Taiwan itself."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reflections
&lt;/h2&gt;

&lt;p&gt;This book is full of many rural legends, and the final section, which includes one of his research reports and actual events, will leave you astonished. First, the book begins by sharing stories of Japanese ghosts and monsters, reflecting on the origins of many ghost stories behind Japanese mythology. It also discusses the connection between Japanese sex and violence and their many ghost stories.&lt;/p&gt;

&lt;p&gt;The second part shares some Taiwan-related stories and also discusses the origin of the "Turbo Granny" in &lt;em&gt;Dandadan&lt;/em&gt; and the stories related to the Slit-Mouthed Woman. These will make you want to read it all in one breath. As the Ghost Month seems to be approaching again recently, it seems this series of books will become popular again. Everyone should check it out.&lt;/p&gt;

</description>
      <category>books</category>
      <category>reviews</category>
    </item>
    <item>
      <title>[Book Sharing] Taiwan's AI Future: Analyzing Trends, Local Landscape, Corporate Strategy, and Personal Development</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Thu, 23 Jul 2026 13:25:21 +0000</pubDate>
      <link>https://dev.to/evanlin/book-sharing-taiwans-ai-future-analyzing-trends-local-landscape-corporate-strategy-and-40io</link>
      <guid>https://dev.to/evanlin/book-sharing-taiwans-ai-future-analyzing-trends-local-landscape-corporate-strategy-and-40io</guid>
      <description>&lt;p&gt;&lt;a href="https://moo.im/a/02oszP" rel="noopener noreferrer"&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%2Fechi9y8wjct6t3nc9ave.jpg" width="210" height="293"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Taiwan's AI Future
Analyzing the latest AI trends, Taiwan's situation, corporate strategies, and personal development
Author: Chien Lee-feng, Hsiao Yu-pin  
Publisher: Business Weekly 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Recommended links to buy the book:
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Readmoo: &lt;a href="https://moo.im/a/02oszP" rel="noopener noreferrer"&gt;Click here to buy&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Preface:
&lt;/h1&gt;

&lt;p&gt;This is the second book I finished reading in 2026. It is a fairly new book, released at the end of 2025. I bought it because my company invited Chien Lee-feng to give a speech in 2024. Later, I happened to see his book on my e-book shelf and decided to take a look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;When AI rewrites the world, what is Taiwan's next step?
Former Managing Director of Google Taiwan, computer scientist, and AI scholar—Chien Lee-feng
Writes the first instruction manual for the AI era for Taiwan,
Helping Taiwanese people understand the opportunities and challenges of the AI age!

The world undergoes a digital revolution every ten years:
● 1990: Personal computers start the computer generation;
● 2000: The Internet creates the web generation;
● 2010: Mobile devices and social media lead the mobile generation;
● 2020: Generative AI like ChatGPT makes a shocking debut...
Now is the AI generation, where the rules of the game are completely rewritten, and the gap is rapidly widening between 1:99.
Will you fall behind and be eliminated, or seize the opportunity and become a 1% winner?
This book takes you deep into the AI landscape to master the key to transformation!

【AI Development under Geopolitics】
As the world undergoes an AI-driven paradigm shift, the US sees AI as the key to its return to hegemony. This not only predicts that AI productization will completely subvert the world's rules of operation but also opens up infinite possibilities for the future evolution of AI. From the "Manhattan Project" to the "Stargate" layout, this book will deeply analyze the global situation under the US-China tariff war and provide insights into how AI reshapes the international order.
● The 1:99 challenge: Countries, companies, and individuals who seize the opportunity may become the unique "1" that far surpasses others, while others become the "99" lagging far behind.
● The emergence of DeepSeek has subverted the US monopoly, bringing a "rebalancing" to the AI world, equivalent to inventing the "poor man's atomic bomb."
● If the pace of domestic chip production is not accelerated, an unproductive America will have no tomorrow and will directly lose competitiveness in the AI battle. TSMC has thus become the X-factor in the US-China confrontation.

【Taiwan Looking at the World】
The AI wave is sweeping the globe; this is not just a technological innovation, but a key turning point for national development. In this giant wave, Taiwan not only has the uniquely endowed "Silicon Shield" TSMC but also sees a golden decade to become "the world's Taiwan" as AI challenges and opportunities coexist. This book gives you a glimpse into the future potential of Taiwan's manufacturing industry and how old and new enterprises are redefining "Made in Taiwan."
● Facing geopolitical changes, Taiwan's manufacturing-oriented enterprises should go with the flow. Through overseas production and R&amp;amp;D in Taiwan, they can create a "Taiwan + N (foreign)" model, helping Taiwan remove the red supply chain and join the US-led supply chain.
● An island's market is always outside. Flying to Japan or the US for travel or business for a few days does not equal internationalization. Internationalization is daily life being impacted by different cultures.

【AI Practice in All Walks of Life】
The AI era is a key moment for corporate transformation and talent reinvention. Only companies that dare to pivot will have competitive opportunities. This book lists cases of how companies in different industries respond to AI and provides practical strategic directions to guide Taiwan's corporate transformation to seize the AI market and move towards growth and innovation.
● The impact of AI can be compared to "musical chairs." From tech giants to SMEs, whether it's "emptying the cage for new birds" (industrial restructuring) or empowering employees, wherever the wind blows, new opportunities lie there.
● "Old-ventures + New-ventures": Shifting from software integration to hardware-software integration, combining the advantages of both, makes AI applications possible.
● Developing Sovereign AI doesn't end with outsourcing. Whether building your own model or asking tech giants for help, the strategy must be planned clearly, otherwise, it might just be a waste of money.

【Mastering the Golden Key to Personal Learning and Career】
As a member of the AI generation, how to use AI to improve learning efficiency while clearly identifying AI's limits is an important task. This book suggests how to use AI tools while pointing out that human differentiated experience will become an irreplaceable treasure. Therefore, cleverly accumulating personal unique value is the only way to remain invincible in the AI era.
● AI likes to use certain specific sentence patterns because AI is a probabilistic concept, so naturally, there are some patterns. But conversely, precisely because its data volume is large enough, it can try various combinations that humans have never seen.
● AI has raised the "passing line" of many jobs from 60 to 80 points in one fell swoop, forcing all industries to redefine the core competencies and value of human labor.
● In the AI era, senior talents with professional foundations learn AI the fastest because their long-term accumulated knowledge can judge the correctness of AI-generated content. This AI paradigm shift has, in turn, amplified the advantages of the older generation.

This is an AI survival guide tailored for Taiwan, helping you fully grasp the context of the AI revolution and find the path to growth for the nation, enterprises, and individuals amidst the changes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This book can be described as an "AI Era Survival Manual" tailored for Taiwanese people and businesses by Dr. Chien Lee-feng, former Managing Director of Google Taiwan, and senior media professional Hsiao Yu-pin. Dr. Chien uses a very pragmatic and precise local perspective to analyze how Taiwan should reposition itself, how companies should play the international game of hardware-software integration, and how individuals can avoid falling into the crisis of "brain outsourcing" under this crazy AI wave.&lt;/p&gt;

&lt;p&gt;I have organized the four core frameworks of the book for you to help you grasp the overall context through this overview:&lt;/p&gt;

&lt;h3&gt;
  
  
  Overview of the Book's Four Core Frameworks
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Category&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Core Pain Points &amp;amp; Trends&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Breakthrough Strategies for Taiwan &amp;amp; Individuals&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1. Latest AI Trends&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AI brings high centralization and unification, potentially evolving into a 1:99 disparity in capabilities and resources; however, the rise of emerging forces like DeepSeek is bringing "rebalancing" opportunities to the world.&lt;/td&gt;
&lt;td&gt;Understand the essence of AI's "probability and language architecture," find breakthroughs outside of US monopolies, and raise the lower limit of basic capabilities.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2. Taiwan's Positioning&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Although Taiwan is a key X-factor in geopolitics and AI chips, it also faces structural constraints like island involution, a declining birthrate, and the "five shortages."&lt;/td&gt;
&lt;td&gt;Completely shift from a "farmer's mindset" to a "navigator's mindset," taking "going global" as the only way to survive, and stepping beyond Taiwan's borders to expand digital territory.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3. Corporate Transformation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Taiwan is "extremely strong in hardware, extremely weak in software." Software startups lacking computing power and business scenarios find it hard to survive independently on the international stage.&lt;/td&gt;
&lt;td&gt;Promote "Old-ventures (hardware giants) + New-ventures (software applications)" collaboration, using Edge AI to add "brains" to powerful hardware devices.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;4. Personal Development Keys&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facing the invisible crisis of "brain outsourcing," mediocre professional newcomers who only teach by the book and lack practical experience will be the first to be hit.&lt;/td&gt;
&lt;td&gt;Shift from a "problem-solving" habit to a "problem-posing" mindset, creating unique value through high-frequency "repeated interaction and correction" with AI.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  I. Latest AI Trends: The 1:99 "Superhuman" Challenge
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extreme Concentration of Power&lt;/strong&gt;: The AI era has brought high centralization, with global tech giants holding a massive advantage. Among thousands of languages worldwide, only about a hundred can be used in mainstream AI, and English and Simplified Chinese are deeply optimized. This means language and cultural frameworks are the primary keys to mastering AI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 1:99 Watershed&lt;/strong&gt;: The cruelest part of this tsunami is not the elimination of ordinary people at the bottom (AI actually raises the floor for ordinary people), but the elimination of "mediocre professionals." The 1% who seize the opportunity will become superhumans through AI, taking the capabilities and opportunities of the 99%, while others become the lagging 99%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebalancing the AI World&lt;/strong&gt;: The recent emergence of non-US low-cost, high-efficiency models has broken the absolute monopoly of US tech giants. This has been described as inventing the "poor man's atomic bomb," bringing an opportunity for a reshuffle to countries and enterprises with fewer resources.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  II. Taiwan's Situation: From "Island Involution" to the "Age of Discovery"
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Geopolitical X-Factor&lt;/strong&gt;: TSMC and Taiwan's hardware supply chain hold a key position in the US-China tech confrontation because Taiwan possesses the characteristic of "knowing the demand earliest" (e.g., being able to grasp system requirements like server voltage changes first), giving it an important identity in the adjustment of global infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking the Farmer's Mindset&lt;/strong&gt;: Most Taiwanese companies are used to an "island mindset," where the "sea is invisible" in daily life, making it easy to fall into involution within a comfortable echo chamber. Facing the structural crisis of a declining birthrate and plunging newborn numbers over the next 20 years, Dr. Chien urgently calls for a shift to a "navigator's mindset," because "going global" is the only way for all industries in Taiwan to survive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extension of Digital Territory&lt;/strong&gt;: Taking TSMC as an example, AI allows Taiwan to replicate factories overseas and have them operated remotely by Taiwanese engineers. Taiwan should also turn the crises of aging and labor shortages into opportunities by actively developing robotics and its own Sovereign AI to avoid a national-level digital divide.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  III. Corporate Layout: Hardware-Software Integration, Letting "Old-ventures + New-ventures" Dance Together
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Using Edge AI to Add Brains to Hardware&lt;/strong&gt;: Edge AI (referring to terminal devices having local computing power without relying entirely on the cloud) is Taiwan's domain. It is hard for pure software startups in Taiwan to compete with international giants, but we can embed and bundle AI services directly into powerful hardware devices used worldwide (such as Giant bicycles or various terminal equipment), significantly increasing added value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old-ventures plus New-ventures Playing the International Game&lt;/strong&gt;: Today's AI startups can hardly succeed without the data, computing power, and real "business scenarios" provided by a "rich father." Therefore, hardware giants (Old-ventures) should join hands with software startups, combining the international channels of the former with the flexible applications of the latter to go global as a team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pragmatic Planning for Sovereign AI&lt;/strong&gt;: Developing Sovereign AI cannot just be about blindly outsourcing business to tech giants. Whether building their own models or cooperating with major manufacturers, enterprises must clearly plan their own strategies and field applications; otherwise, it is just a waste of money.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  IV. Personal Development: Refuse "Brain Outsourcing," Be a High-Level "Questioner"
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shift Thinking from "Solving" to "Posing"&lt;/strong&gt;: AI's capabilities are "asked" out. The more professional the question, the more accurate the response. The future workplace will no longer value rote memorization; core competencies will shift to problem definition, critical thinking, and direction control. Only "questioners" who can demonstrate proactive influence will win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using "Iterative Refinement" to Deepen Learning&lt;/strong&gt;: If you just throw a question to AI, get an answer once, and copy it directly, this behavior is equivalent to plagiarism. However, if you can go back and forth with AI to modify it 10 times, that is a "learning" process; if you continue to repeatedly correct and adjust up to 100 times, that is truly approaching the level of "creation."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accumulating Irreplaceable "Differentiated Experience"&lt;/strong&gt;: Functions like memory and calculation can be outsourced to AI, but your unique personal experience, cross-disciplinary collaboration ability (π-shaped talent), and human critical thinking are the irreplaceable treasures of the AI era. Cleverly using AI tools to amplify your output is the only way to avoid becoming the "lost generation" eliminated by the times.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Core Soul Quote of the Book:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Change is humanity's eternal unease, but from a macro perspective, what AI brings is an opportunity to make humans more capable." When calculation and memory are outsourced from the brain, be sure to retain your power of thinking and creation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This video &lt;a href="https://www.youtube.com/watch?v=XHTfeCk0GwQ" rel="noopener noreferrer"&gt;Interview with Dr. Chien Lee-feng: Who is the Lost Generation of the AI Era&lt;/a&gt; deeply explores the "1:99 Superhuman Challenge" and workplace mindset transformation mentioned in the book, helping you more intuitively understand how to retain personal competitiveness in this era of brain outsourcing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Personal Thoughts
&lt;/h2&gt;

&lt;p&gt;From my own perspective, this book organizes many recent AI development processes both domestically and internationally. It provides many future insights based on Chien Lee-feng's own experience as the former Managing Director of Google Taiwan. It often shares advice on how various industries should face the AI era. This part is frequently mentioned in his speeches and is shared and explained quite clearly.&lt;/p&gt;

&lt;p&gt;Personally, I feel that one can just skim through this book. In comparison, I still prefer Dr. Chien Lee-feng's speeches, which provide more impact and inspiration.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>books</category>
      <category>career</category>
      <category>learning</category>
    </item>
    <item>
      <title>[GCP in Action] LINE Business Card Bot Evolution: Dual-Side Recognition and Merging with Gemini</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Thu, 23 Jul 2026 13:24:50 +0000</pubDate>
      <link>https://dev.to/gde/gcp-in-action-line-business-card-bot-evolution-dual-side-recognition-and-merging-with-gemini-2220</link>
      <guid>https://dev.to/gde/gcp-in-action-line-business-card-bot-evolution-dual-side-recognition-and-merging-with-gemini-2220</guid>
      <description>&lt;h1&gt;
  
  
  Pain Point: One Chinese Business Card is Actually Two Business Cards
&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%2Fcej08mt7097plgtj10tu.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%2Fcej08mt7097plgtj10tu.png" alt="image-20260723152322389" width="800" height="682"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Business cards in Taiwan often have a common design: Chinese printed on the front and English on the back (or vice versa). Our LINE business card bot's original logic was very simple: receive an image, perform OCR once, and save one record.&lt;/p&gt;

&lt;p&gt;This is where the problem lies. When a user sends the front side, the bot saves a record with only the Chinese name. If the user then sends the back side, the bot treats it as "another new business card," resulting in two records for the same person in the database, each missing half the information. Users have to manually compare and delete duplicate data, which is a terrible experience.&lt;/p&gt;

&lt;p&gt;This article records how we taught the bot to recognize that "these are two sides of the same business card" and merge the information from both sides into a single complete record.&lt;/p&gt;




&lt;h1&gt;
  
  
  Solution: First Ask, "Is there a back side?"
&lt;/h1&gt;

&lt;p&gt;Instead of writing rules to guess if two images are the same business card, we chose a more direct approach: &lt;strong&gt;Ask the user&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The workflow is designed as follows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user sends the front of the business card, and the bot performs OCR as usual.&lt;/li&gt;
&lt;li&gt;After OCR is complete, the bot &lt;strong&gt;does not save immediately&lt;/strong&gt;. Instead, it replies with "📇 Front side data recognized. Is there a back side to this card?" and provides two Quick Reply buttons.&lt;/li&gt;
&lt;li&gt;User clicks "No, save directly" → Save according to the original process and finish.&lt;/li&gt;
&lt;li&gt;User clicks "Yes, there's a back side" → The bot remembers the front image and waits for the next image.&lt;/li&gt;
&lt;li&gt;Once the back side image arrives, both images are sent to Gemini together to be merged into a single record before saving.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We manage this waiting state using the &lt;code&gt;user_states&lt;/code&gt; memory dictionary already present in the project, and add a 5-minute timeout. If a user clicks "Yes, there's a back side" but then ignores it or does something else, the process is treated as abandoned after 5 minutes, preventing the entire workflow from getting stuck.&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;user_states&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="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;action&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;pending_backside_confirm&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;card_obj&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;card_obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;front_image_bytes&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image_content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expires_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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PENDING_BACKSIDE_TIMEOUT_SECONDS&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Core: Let Gemini See Two Images at Once and Merge Them
&lt;/h1&gt;

&lt;p&gt;The most critical technical decision was: should we recognize the front and back sides separately and then write code to merge them? We chose another path: wrapping both the front and back images in a single &lt;code&gt;generate_content&lt;/code&gt; request and letting Gemini handle the judgment directly.&lt;/p&gt;

&lt;p&gt;The reason is simple: merging Chinese and English names into a format like "Wang Daming David Wang" using string rules is prone to being messy and inaccurate. Semantic-level integration is more likely to fail with hardcoded rules, so letting Gemini handle it directly is much easier.&lt;/p&gt;

&lt;p&gt;In &lt;a&gt;app/gemini_utils.py&lt;/a&gt;, we added &lt;code&gt;generate_json_from_two_images&lt;/code&gt;, which reuses the existing &lt;code&gt;NAMECARD_SCHEMA&lt;/code&gt; structured output, but this time the &lt;code&gt;contents&lt;/code&gt; includes two image Parts:&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_json_from_two_images&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;front_img&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;back_img&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Image&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;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;object&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="nc"&gt;GenerativeModel&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-flash-preview&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;response_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;application/json&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;response_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NAMECARD_SCHEMA&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;front_part&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="nf"&gt;from_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;pil_to_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;front_img&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;image/jpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;back_part&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="nf"&gt;from_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;pil_to_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;back_img&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;image/jpeg&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="n"&gt;model&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="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="n"&gt;front_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;back_part&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;stream&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;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;namecard&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;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt also just adds a merge instruction after the original &lt;code&gt;IMGAGE_PROMPT&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="n"&gt;DOUBLE_SIDED_IMAGE_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;IMGAGE_PROMPT&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
These two images are the front and back of the same business card; please integrate them into a single complete record.
If both Chinese and English appear in the same field (such as name or company), please present them merged
(e.g., &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Wang Daming David Wang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;);
if a field appears on only one side, use the value from that side; ignore obviously redundant information.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One API call means we don't have to write or maintain any merging rules ourselves.&lt;/p&gt;




&lt;h1&gt;
  
  
  Two Easily Overlooked Pitfalls
&lt;/h1&gt;

&lt;p&gt;During the overall code review before the feature went live, we caught two details that are easily overlooked but can really cause issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 1: Timing of Duplicate Checks
&lt;/h3&gt;

&lt;p&gt;Originally, the duplicate check (comparing if the email already exists) was done immediately after OCR. However, after the double-sided recognition went live, if the front side happened to have a duplicate email from an old record, the process would prematurely determine it "already exists" and end. This would mean any new email on the back side would never be seen.&lt;/p&gt;

&lt;p&gt;The fix was to move the duplicate check later, performing it only after "single-side chosen not to merge" or "double-sided merge complete." This ensures we are always comparing the final version of the data:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_finalize_and_save_card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card_obj&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;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;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;existing_card_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firebase_utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_if_card_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card_obj&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing_card_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# ... Reply already exists
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;card_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firebase_utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_namecard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card_obj&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="c1"&gt;# ... Reply save successful
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the single-sided and double-sided merge workflows eventually converge to call this shared function, ensuring the duplicate check is executed only once when the data is finalized.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 2: Don't Clear All States Indiscriminately
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;user_states&lt;/code&gt; dictionary is actually shared by several features: editing memos, modifying fields, and this back-side recognition. The initial implementation, for convenience, would delete the entire state whenever a residual state was detected before processing a new event.&lt;/p&gt;

&lt;p&gt;The problem is: if a user is "editing the phone field" and waiting to input a new number, but accidentally sends an image, this logic would clear the &lt;code&gt;editing_field&lt;/code&gt; state as well, silently canceling the user's original editing operation.&lt;/p&gt;

&lt;p&gt;The fix was to only clear the two states related to the back-side recognition process and leave other states untouched:&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="n"&gt;state&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;action&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pending_backside_confirm&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;awaiting_backside_image&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;user_states&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We later found a missing branch in the same logic; the part handling "operation expired" replies also cleared everything initially. It was only truly fixed after unifying it with the same selective judgment.&lt;/p&gt;




&lt;h1&gt;
  
  
  Incidental Resource Cleanup: Don't Let Back-Side Images Linger in Memory
&lt;/h1&gt;

&lt;p&gt;The &lt;code&gt;awaiting_backside_image&lt;/code&gt; state stores not just text, but also the raw byte data of the front image. If a user disappears after being asked "Is there a back side?", this data would theoretically stay in the process memory because the original design only checked and cleared timeout states during the "user's next interaction."&lt;/p&gt;

&lt;p&gt;We added a &lt;code&gt;sweep_expired_states()&lt;/code&gt; function, which runs immediately when a Webhook comes in. It clears all expired temporary states for all users, so we don't have to wait for the specific user to return for passive cleanup:&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;sweep_expired_states&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;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;now&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="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;expired_user_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;for&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;state&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;user_states&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expires_at&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expires_at&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&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;user_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;expired_user_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;user_states&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whenever any user sends a message, it performs garbage collection for all users, ensuring that those who abandoned the process don't leave behind memory-consuming remnants.&lt;/p&gt;




&lt;h1&gt;
  
  
  Summary and Benefits
&lt;/h1&gt;

&lt;p&gt;This double-sided recognition and merging feature makes the LINE business card bot much more aligned with the actual usage habits of Taiwanese users:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Single Recognition, Complete Data&lt;/strong&gt;: Both sides are sent to Gemini at once, automatically merging Chinese and English fields, eliminating the need for manual duplicate comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-Intrusive&lt;/strong&gt;: Ignoring prompts, timeouts, or temporarily doing something else will naturally revert to single-sided storage without getting the workflow stuck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate Checks Target Final Data&lt;/strong&gt;: Ensures that comparisons are always made against the merged, complete version, so new information appearing only on the back side isn't missed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent State Machines&lt;/strong&gt;: The temporary state for back-side recognition only affects itself and doesn't interfere with other ongoing user operations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean Memory Usage&lt;/strong&gt;: Proactively cleaning up timeout states ensures that users who abandon the process don't leave an invisible memory burden.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The complete code has been pushed to &lt;a href="https://github.com/kkdai/linebot-namecard-python" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, feel free to check it out!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cloud</category>
      <category>gemini</category>
      <category>google</category>
    </item>
  </channel>
</rss>
