<?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>[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>
    <item>
      <title>[Digital Certificate Wallet] Advanced: Building "Visitor-Endorsed Issuance" – A Full-Chain DID Application as Both Verifier and</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Mon, 13 Jul 2026 08:01:01 +0000</pubDate>
      <link>https://dev.to/evanlin/digital-certificate-wallet-advanced-building-visitor-endorsed-issuance-a-full-chain-did-4hlc</link>
      <guid>https://dev.to/evanlin/digital-certificate-wallet-advanced-building-visitor-endorsed-issuance-a-full-chain-did-4hlc</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.amazonaws.com%2Fuploads%2Farticles%2Fzt11xi6eefeljlva2rz6.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.amazonaws.com%2Fuploads%2Farticles%2Fzt11xi6eefeljlva2rz6.png" alt="image-20251009102618401" width="799" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(Source: &lt;a href="https://www.wallet.gov.tw/zh-tw" rel="noopener noreferrer"&gt;Digital Certificate Wallet Official Website&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Premise:
&lt;/h2&gt;

&lt;p&gt;The previous &lt;a href="https://github.com/kkdai/did-usecase-HR" rel="noopener noreferrer"&gt;introductory version&lt;/a&gt; created an HR employee card system: colleagues apply for an employee card themselves and then use it to apply for "sports subsidies" and "childcare subsidies." The focus of that article was on separating the two roles: "&lt;strong&gt;Issuer&lt;/strong&gt;" and "&lt;strong&gt;Verifier&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;In this article, I want to take it a step further: what does it look like if a scenario needs to &lt;strong&gt;play both the verifier and issuer roles simultaneously&lt;/strong&gt; to form a complete DID ecosystem chain? The scenario I chose is "&lt;strong&gt;Visitor Endorsement and Issuance&lt;/strong&gt;"—this is also the one I felt best demonstrates the "full chain" when brainstorming five verifier applications.&lt;/p&gt;

&lt;p&gt;By the way, this article will honestly document &lt;strong&gt;three pitfalls encountered&lt;/strong&gt; during the development process, as those are the truly valuable parts of TIL (Today I Learned).&lt;/p&gt;

&lt;p&gt;Code is here: &lt;a href="https://github.com/kkdai/did-usecase-visitor" rel="noopener noreferrer"&gt;https://github.com/kkdai/did-usecase-visitor&lt;/a&gt;&lt;br&gt;
Online experience: &lt;a href="https://did-usecase-visitor-660825558664.asia-east1.run.app" rel="noopener noreferrer"&gt;https://did-usecase-visitor-660825558664.asia-east1.run.app&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Scenario: Employee Access Control + Visitor Endorsement and Issuance
&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%2Fngkv6rlywiutybkd69lo.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%2Fngkv6rlywiutybkd69lo.png" alt="image-20260709172435871" width="800" height="1734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This lobby reception desk has two modes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Employee Access Control / Event Registration&lt;/strong&gt;: Employees present their employee cards using a digital wallet. The system only verifies "&lt;strong&gt;whether they are a valid employee&lt;/strong&gt;." Once verified, the door opens or registration is successful. Fields like name, birthday, and number of children are not disclosed and remain in the wallet—this is selective disclosure.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Visitor Endorsement and Issuance&lt;/strong&gt; (the protagonist of this article): An active employee presents their employee card to "endorse" the visitor. After verification, the system &lt;strong&gt;immediately issues a temporary visitor pass with an expiration time&lt;/strong&gt; to the visitor's wallet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The value of the second mode lies in connecting the two roles:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;First act as a &lt;strong&gt;Verifier&lt;/strong&gt; (verify employee card) → then act as an &lt;strong&gt;Issuer&lt;/strong&gt; (issue visitor card) only after verification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Compared to traditional paper visitor logs (copying IDs, holding physical IDs, piles of personal data at the counter requiring manual disposal), digital endorsement only leaves one piece of accountable information: "which employee endorsed it." Visitor data stays in the visitor's own wallet, and the pass can have an expiration time.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture Decisions: Why not just modify the previous project?
&lt;/h2&gt;

&lt;p&gt;This time, I started a brand new project and deployed it to a separate Cloud Run service instead of adding pages to the original HR project. Several considerations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Static Frontend + JSON API&lt;/strong&gt;: The original project used jade templates for server-side rendering. This time, it was changed to &lt;code&gt;public/&lt;/code&gt; static pages + a few JSON APIs (&lt;code&gt;/api/access/qrcode&lt;/code&gt;, &lt;code&gt;/api/access/status&lt;/code&gt;), separating the frontend and backend more cleanly.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Abstracting Wallet API calls into &lt;code&gt;lib/wallet.js&lt;/code&gt;&lt;/strong&gt;: In the original project, issuer/verifier calls were embedded in routes and duplicated. This time, they were extracted into three functions: &lt;code&gt;requestPresentationQRCode()&lt;/code&gt;, &lt;code&gt;getPresentationResult()&lt;/code&gt;, and &lt;code&gt;issueCredential()&lt;/code&gt;, making it easier to maintain and test.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;State changed to Memory&lt;/strong&gt;: The original project wrote data to a single &lt;code&gt;record.js&lt;/code&gt; file. Writing files in a stateless environment like Cloud Run causes issues. This time, a simple memory object was used (resets on restart, sufficient for demonstration purposes).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Reusing the same Sandbox Account tokens&lt;/strong&gt;: The access tokens for the issuer/verifier are the same set as in the previous article, reused directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the "holding an employee card" verification part, I first used the existing sports subsidy verifier ref as a fallback (if &lt;code&gt;VERIFIER_ACCESS_REF&lt;/code&gt; is not set, use &lt;code&gt;VERIFIER_SPORT_REF&lt;/code&gt;), so it could run without waiting for backend configuration.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pitfall Record 1: Presentation successful, but the screen is stuck
&lt;/h2&gt;

&lt;p&gt;This is a classic one. The phone scans the code, and the wallet completes the presentation, but the desktop page just won't move forward; it keeps polling.&lt;/p&gt;

&lt;p&gt;First, checking the Cloud Run logs, I found that &lt;code&gt;/api/access/status&lt;/code&gt; returns every 3 seconds, each time returning "unverified." I added a log line on the backend to print the &lt;strong&gt;original response&lt;/strong&gt; from the verifier. After redeploying and testing again, I caught the truth:&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;"data"&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;"credentialType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0028680530_line_school"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"claims"&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;"ename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ename"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"English Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Lub"&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;"ename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"join_company"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Join Date"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2018-10-05"&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="nl"&gt;"verifyResult"&lt;/span&gt;&lt;span class="p"&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="nl"&gt;"resultDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transactionId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8cd7f37b-..."&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;See the problem? The field in the response is &lt;strong&gt;&lt;code&gt;verifyResult&lt;/code&gt; (camelCase)&lt;/strong&gt;, and &lt;strong&gt;there is no &lt;code&gt;code&lt;/code&gt; field at all&lt;/strong&gt;. But I used the old logic from the previous article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Old (doesn't match current response)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verify_result&lt;/span&gt; &lt;span class="o"&gt;===&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;data.code&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, and &lt;code&gt;data.verify_result&lt;/code&gt; is also &lt;code&gt;undefined&lt;/code&gt; (it's called &lt;code&gt;verifyResult&lt;/code&gt;), so it's always &lt;code&gt;false&lt;/code&gt;, always pending. &lt;strong&gt;Actually, the verification had already succeeded&lt;/strong&gt; (&lt;code&gt;verifyResult: true&lt;/code&gt;, &lt;code&gt;resultDescription: "success"&lt;/code&gt;), but the field names I was checking didn't match—it seems the sandbox API response format has changed from snake_case to camelCase.&lt;/p&gt;

&lt;p&gt;The fix was to change the logic to be compatible with both formats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verifyResult&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="c1"&gt;// New format camelCase&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verify_result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="c1"&gt;// Old format compatibility&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verify_result&lt;/span&gt; &lt;span class="o"&gt;===&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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TIL&lt;/strong&gt;: When integrating third-party APIs, don't trust that "the logic that worked in the last version will work in this one." Sandboxes change. Adding a log line to print the raw response and comparing it is much faster than staring at the code and guessing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Pitfall Record 2: Visitor card stuck at "Pending Issuance"
&lt;/h2&gt;

&lt;p&gt;After the access control part worked, the visitor endorsement part got stuck—the screen showed "Visitor card pending issuance (issuer template not set)," and no card was actually issued.&lt;/p&gt;

&lt;p&gt;I used &lt;code&gt;curl&lt;/code&gt; to hit the issuance API &lt;code&gt;/api/vc-item-data&lt;/code&gt; directly to see what it returned. I tested two scenarios:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario A: Using employee template + correct employee fields&lt;/strong&gt; → HTTP 200, and the full response contained these keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KEYS: ['id', 'content', 'pureContent', ..., 'qrCode', 'deepLink', 'expired', ...]
qrCode = data:image/png;base64,iVBOR... ← QR code that can actually be scanned into the wallet
deepLink = https://frontend-uat.wallet.gov.tw/api/moda/vcqrcode?...
expired = 2027-01-09T...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Scenario B: Using employee template + visitor fields&lt;/strong&gt; (&lt;code&gt;visitor_type&lt;/code&gt;, &lt;code&gt;endorsed_by&lt;/code&gt;…) → HTTP 500 / 400 BAD_REQUEST.&lt;/p&gt;

&lt;p&gt;The reason was clear: the employee template fields were &lt;code&gt;isRequired: true&lt;/code&gt; (Name, English Name…), but I sent a bunch of visitor fields it didn't have, so it was rejected. And the &lt;strong&gt;successful issuance response actually includes &lt;code&gt;qrCode&lt;/code&gt; and &lt;code&gt;deepLink&lt;/code&gt;&lt;/strong&gt;, which can be used directly for the visitor to scan and collect the card—my original parsing was correct; the bottleneck was purely "fields not matching the template."&lt;/p&gt;

&lt;p&gt;So I designed two issuance modes, automatically switched by environment variables (&lt;code&gt;HAS_VISITOR_TEMPLATE&lt;/code&gt; in the code):&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;Condition&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Card Face&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Option 1 (fallback)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;VISITOR_VC_*&lt;/code&gt; not set&lt;/td&gt;
&lt;td&gt;Borrow the employee template, stuffing visitor info into its required fields (Name="Temporary Visitor", etc.) to issue the card&lt;/td&gt;
&lt;td&gt;Displays as an employee card face&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Option 2 (Formal)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;VISITOR_VC_*&lt;/code&gt; set&lt;/td&gt;
&lt;td&gt;Send &lt;code&gt;visitor_type / endorsed_by / valid_until&lt;/code&gt; to the dedicated visitor template&lt;/td&gt;
&lt;td&gt;Formal visitor pass card face&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The advantage of Option 1 is that &lt;strong&gt;a real, collectable card can be issued without waiting for backend configuration&lt;/strong&gt; (even if the card face is borrowed), allowing the entire chain to be tested first; for a formal card face, just go with Option 2 and build a dedicated template, with no code changes required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall Record 3: Collection QR too small + Mobile layout
&lt;/h2&gt;

&lt;p&gt;In the first version, I made the visitor pass look like a pretty little ID badge, and the collection QR was only 48px—the result was that &lt;strong&gt;it couldn't be scanned at all&lt;/strong&gt;. This QR is meant to be scanned by "another phone" to collect the card; if it's too small, it loses its purpose.&lt;/p&gt;

&lt;p&gt;Later, I changed the visitor card to a vertical layout, enlarging the collection QR to be the main body of the card (max 240px, white background with padding), with "Endorsed by / Valid until" information placed below. Both QRs (for presentation and collection) were also changed to &lt;code&gt;clamp()&lt;/code&gt; responsive sizes, so they don't break the layout on mobile and are clear enough on desktop.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TIL&lt;/strong&gt;: As long as a QR is "for others to scan," it must be treated as the protagonist of the layout, not as a decoration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Truth About "Automatic Expiration"
&lt;/h2&gt;

&lt;p&gt;I originally thought I could specify "this visitor pass expires in 4 hours" for each card, but testing revealed that when issuing cards via &lt;code&gt;/api/vc-item-data&lt;/code&gt;, the actual validity of the card &lt;strong&gt;follows the template settings&lt;/strong&gt; (e.g., the employee template is issuance date + about half a year); it's not possible to specify a short expiration for individual cards.&lt;/p&gt;

&lt;p&gt;So the "Valid until HH:MM" on the card face now is a &lt;strong&gt;display value calculated by the application layer&lt;/strong&gt;, not a mandatory expiration enforced by the wallet. If a truly short-term visitor pass is needed, there are two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  When creating the visitor template, &lt;strong&gt;set the template's validity period to be short&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;  Or use the platform's &lt;strong&gt;scheduled revocation (revoke)&lt;/strong&gt;—the issuance response includes fields like &lt;code&gt;clearScheduleId&lt;/code&gt; and &lt;code&gt;scheduleRevokeMessage&lt;/code&gt;, implying the platform supports scheduled revocation, but this requires integrating the corresponding API separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Deployment: Directly to Cloud Run from Source Code
&lt;/h2&gt;

&lt;p&gt;This time, I used buildpacks to deploy directly from source code without writing a Dockerfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy did-usecase-visitor &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-east1 &lt;span class="nt"&gt;--platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;managed &lt;span class="nt"&gt;--allow-unauthenticated&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set-env-vars&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"VC_SERNUM=607861,VC_UID=0028680530_line_school,&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
ISSUER_ACCESS_TOKEN=...,VERIFIER_SPORT_REF=...,VERIFIER_ACCESS_TOKEN=...,&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
VISITOR_TTL_HOURS=4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To switch to the formal visitor card in Option 2 later, just add &lt;code&gt;VISITOR_VC_SERNUM=&amp;lt;new template vcId&amp;gt;,VISITOR_VC_UID=&amp;lt;new template vcCid&amp;gt;&lt;/code&gt; to this &lt;code&gt;--set-env-vars&lt;/code&gt; string and redeploy; &lt;code&gt;HAS_VISITOR_TEMPLATE&lt;/code&gt; will automatically become true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary and Future Outlook
&lt;/h2&gt;

&lt;p&gt;The focus this time wasn't "making another demo," but three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;The full DID chain is feasible&lt;/strong&gt;: Playing both Verifier and Issuer in the same scenario—verifying one card and then issuing another—connects the ecosystem chain, and the experience is smooth.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Pitfalls are in the details&lt;/strong&gt;: Field naming (&lt;code&gt;verifyResult&lt;/code&gt; vs &lt;code&gt;verify_result&lt;/code&gt;), mandatory template fields, QR size—these wouldn't be discovered without looking at the raw response and actually scanning with a phone.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Fallback design allows the demo to work first&lt;/strong&gt;: No need to wait for every template/ref to be built on the backend; use existing resources to get the whole chain running first, then gradually switch to formal settings. The development rhythm is much better.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There are truly many application scenarios for digital certificate wallets; "visitor endorsement" is just one of them. The employee card from the previous article could also be extended to commissary discount redemption, seniority milestone gifts, childcare facility access, gym point accumulation... each is a new application for a "verifier." I look forward to seeing more creative scenarios being built.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>blockchain</category>
      <category>tutorial</category>
      <category>web3</category>
    </item>
    <item>
      <title>[GCP Billing &amp; Vertex AI] Solving Gemini Cost Allocation in a Single Project: Vertex AI Dynamic Billing Labels in Action</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Sun, 12 Jul 2026 15:09:09 +0000</pubDate>
      <link>https://dev.to/gde/gcp-billing-vertex-ai-solving-gemini-cost-allocation-in-a-single-project-vertex-ai-dynamic-5aok</link>
      <guid>https://dev.to/gde/gcp-billing-vertex-ai-solving-gemini-cost-allocation-in-a-single-project-vertex-ai-dynamic-5aok</guid>
      <description>&lt;h1&gt;
  
  
  Pain Point: How to Accurately Allocate Gemini API Costs Within the Same Project?
&lt;/h1&gt;

&lt;p&gt;When developing enterprise-level LLM services or operating multi-tenant platforms, the question most frequently asked by finance and operations teams is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We have many different business lines and LINE Bots connected within the same GCP project. Every day, the Gemini Key costs all appear under the Gemini API category. Is there a way for us to split the costs based on different Gemini Keys or different users?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Direct answer to your question:&lt;/strong&gt; In Google Cloud Billing reports, it is &lt;strong&gt;not possible to directly display costs "based on different API Key names."&lt;/strong&gt; The smallest attribution dimensions for Google Cloud billing reports are "Project," "Service," and "SKU (Product Line Item)." The system does not treat individual API Key strings as independent billing items. For the billing system, whether you create 10 or 100 API Keys within the same project, they will all be lumped together as a single Gemini API total.&lt;/p&gt;




&lt;h1&gt;
  
  
  A Workaround: Vertex AI "Request Labels" to the Rescue
&lt;/h1&gt;

&lt;p&gt;If architectural constraints force you to stay within the same project, the most recommended approach is: &lt;strong&gt;switch to Vertex AI calls and use "Request Labels."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you are currently using a Google AI Studio API Key, it cannot pass billing labels within a single project. However, if you change your code to call the &lt;strong&gt;Vertex AI Gemini API&lt;/strong&gt; (still within the same project), Vertex AI supports dynamically including custom &lt;code&gt;labels&lt;/code&gt; with each request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Principle and Workflow
&lt;/h3&gt;

&lt;p&gt;When sending each request (e.g., calling &lt;code&gt;generateContent&lt;/code&gt;), include specific metadata in the API Request:&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;"contents"&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="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="nl"&gt;"labels"&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;"client_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;"info_helper"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"api_key_group"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"marketing_team"&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;These custom labels are passed directly to the GCP billing system. Later, when you go to the GCP Billing report and select your set label key (e.g., &lt;code&gt;client_id&lt;/code&gt;) in "Group by," you can clearly see the costs for different labels (representing different services, clients, or users) within the same project!&lt;/p&gt;




&lt;h1&gt;
  
  
  Project Implementation: Full Adoption of the Labels Mechanism
&lt;/h1&gt;

&lt;p&gt;To fulfill this requirement, we audited the current API call architecture of the LINE Bot project and performed the following refactoring.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Project API Call Audit
&lt;/h3&gt;

&lt;p&gt;Through scanning, we found that the vast majority of calls in the project use Vertex AI (14 out of 17 clients use &lt;code&gt;vertexai=True&lt;/code&gt;), with only a few exceptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vertex AI calls&lt;/strong&gt;: Including GitHub summaries, multiple Google Maps Grounding tools, text summarization, image analysis, speech-to-text, etc. (total of 11 files, 19 call points).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini API Key calls&lt;/strong&gt;: Live API in &lt;code&gt;main.py&lt;/code&gt;, Batch service in &lt;code&gt;batch_service.py&lt;/code&gt;, and TTS speech synthesis in &lt;code&gt;tts_tool.py&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;[!IMPORTANT] The &lt;code&gt;labels&lt;/code&gt; parameter is only supported by Vertex AI. If this parameter is included under an API Key (&lt;code&gt;vertexai=False&lt;/code&gt;), it will cause the SDK to throw an error. Therefore, we only modified the 11 files that use Vertex AI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. Implementation Method
&lt;/h3&gt;

&lt;p&gt;For the &lt;code&gt;google-genai&lt;/code&gt; Python SDK, we have two main modification scenarios:&lt;/p&gt;

&lt;h4&gt;
  
  
  Scenario A: Already contains &lt;code&gt;GenerateContentConfig&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;If the original call already includes a Config, we just need to pass an additional &lt;code&gt;labels={"client_id": "info_helper"}&lt;/code&gt; into the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before (e.g., loader/gh_tools.py)
&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;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-2.5-flash&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="mi"&gt;0&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;2048&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# After
&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;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-2.5-flash&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="mi"&gt;0&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;2048&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="c1"&gt;# Include billing label
&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;h4&gt;
  
  
  Scenario B: No Config parameter
&lt;/h4&gt;

&lt;p&gt;If the original call is very simple (e.g., &lt;code&gt;searchtool.py&lt;/code&gt; or &lt;code&gt;youtube_gcp.py&lt;/code&gt;), we need to proactively include a &lt;code&gt;GenerateContentConfig&lt;/code&gt; containing labels:&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;# Before (e.g., loader/searchtool.py)
&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;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-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;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="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# After
&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;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-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;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;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="c1"&gt;# Add config to include label
&lt;/span&gt;    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. List of Modified Files
&lt;/h3&gt;

&lt;p&gt;We performed precise modifications on a total of 19 call points across the following 11 files, and used Python's AST module (&lt;code&gt;ast.parse&lt;/code&gt;) and Flake8 for syntax and formatting checks before submission:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;agents/chat_agent.py&lt;/a&gt;&lt;/strong&gt;: Modify &lt;code&gt;_create_chat_config()&lt;/code&gt; to add labels to both general Q&amp;amp;A and Grounding conversations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;loader/chat_session.py&lt;/a&gt;&lt;/strong&gt;: Include labels in Chat session config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;loader/gh_tools.py&lt;/a&gt;&lt;/strong&gt;: GitHub summary API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;loader/langtools.py&lt;/a&gt;&lt;/strong&gt;: Text summarization, image JSON generation, social media post generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;loader/maps_grounding.py&lt;/a&gt;&lt;/strong&gt;: Maps search API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;loader/searchtool.py&lt;/a&gt;&lt;/strong&gt;: Keyword extraction tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;loader/youtube_gcp.py&lt;/a&gt;&lt;/strong&gt;: YouTube video understanding API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;tools/audio_tool.py&lt;/a&gt;&lt;/strong&gt;: Asynchronous speech-to-text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;tools/maps_tool.py&lt;/a&gt;&lt;/strong&gt;: 5 call points including nearby search, restaurant name extraction, batch and review search, etc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;tools/summarizer.py&lt;/a&gt;&lt;/strong&gt;: Text summarization and Agentic Vision image understanding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a&gt;tools/youtube_tool.py&lt;/a&gt;&lt;/strong&gt;: YouTube summary tool.&lt;/li&gt;
&lt;/ol&gt;




&lt;h1&gt;
  
  
  Pitfall Guide: Watch Out for SDK Module Import Issues
&lt;/h1&gt;

&lt;p&gt;When refactoring calls without Config for &lt;code&gt;youtube_gcp.py&lt;/code&gt; and &lt;code&gt;youtube_tool.py&lt;/code&gt;, since these two files originally only used named imports for specific types:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HttpOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Part&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;When we write &lt;code&gt;types.GenerateContentConfig(...)&lt;/code&gt; in the code, the system throws a &lt;code&gt;NameError: name 'types' is not defined&lt;/code&gt; error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; We need to correct the import statement and directly import &lt;a&gt;GenerateContentConfig&lt;/a&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;# Before
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HttpOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Part&lt;/span&gt;

&lt;span class="c1"&gt;# After
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HttpOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GenerateContentConfig&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;And use it directly in the call without the &lt;code&gt;types.&lt;/code&gt; prefix:&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;config&lt;/span&gt;&lt;span class="o"&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Summary and Next Steps
&lt;/h1&gt;

&lt;p&gt;This modification successfully injected the &lt;code&gt;client_id=info_helper&lt;/code&gt; label into all Vertex AI API calls within the LINE Bot project.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Billing Delay&lt;/strong&gt;: Please note that after we start including &lt;code&gt;labels&lt;/code&gt;, GCP billing data usually has a 24 to 48-hour delay before taking effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure in GCP Billing&lt;/strong&gt;: After two days, you can go to the GCP Console -&amp;gt; &lt;strong&gt;Billing&lt;/strong&gt; -&amp;gt; &lt;strong&gt;Reports&lt;/strong&gt;. In the "Group by" section on the right, select &lt;strong&gt;Labels&lt;/strong&gt; and enter our key &lt;code&gt;client_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mission Accomplished&lt;/strong&gt;: At this point, the report will draw &lt;code&gt;info_helper&lt;/code&gt; as a separate billing row, perfectly solving the problem of separating project costs for reimbursement and statistics!&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>cloud</category>
      <category>google</category>
      <category>llm</category>
    </item>
    <item>
      <title>[AI in Action] Refining a macOS Meeting Translation App with Claude Code: Auto-reconnect, Floating Captions, and Meeting Minutes Export Evolution</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Sun, 05 Jul 2026 11:00:46 +0000</pubDate>
      <link>https://dev.to/gde/ai-in-action-refining-a-macos-meeting-translation-app-with-claude-code-auto-reconnect-floating-2856</link>
      <guid>https://dev.to/gde/ai-in-action-refining-a-macos-meeting-translation-app-with-claude-code-auto-reconnect-floating-2856</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%2Fztv3i129dwrt83h8npqw.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%2Fztv3i129dwrt83h8npqw.png" alt="image-20260702134921415" width="800" height="499"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Foreword: Round Two, Switching to a Sharper Tool
&lt;/h1&gt;

&lt;p&gt;In the &lt;a href="//2026-06-10-agy-macos-app.md"&gt;previous article&lt;/a&gt;, we used &lt;strong&gt;AGY CLI (Antigravity)&lt;/strong&gt; to build a macOS real-time meeting translation App from scratch: capturing audio from Zoom / Google Meet via ScreenCaptureKit, sending it to the Gemini Live API for real-time translation, and displaying Traditional Chinese bilingual subtitles in the App window.&lt;/p&gt;

&lt;p&gt;After the App went live, the developer discovered several issues during actual meetings that raised eyebrows, while also coming up with more functional ideas. This time, we switched to Anthropic's &lt;strong&gt;Claude Code&lt;/strong&gt; to start the second round of deep polishing in the terminal.&lt;/p&gt;

&lt;p&gt;The following records the complete dialogue flow of this collaboration, restoring every key decision point.&lt;/p&gt;




&lt;h1&gt;
  
  
  Phase 1: Uncovering Hidden Dangers — The Mystery of the WebSocket Stopping After 10 Minutes
&lt;/h1&gt;

&lt;p&gt;The App seemed to work perfectly, but the developer returned with a question after a real, long meeting:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: Check this code. Why does the real-time translation stop after about ten minutes? Help me look into possible reasons.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude Code read all five Swift source files and, combined with the built-in Gemini Live API documentation, precisely pointed out the root of the problem:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Gemini Live API's WebSocket connection has a Session limit of about 10 minutes&lt;/strong&gt;. Once the time is up, the server actively closes the connection and sends a &lt;code&gt;GoAway&lt;/code&gt; signal. However, the original code was completely unprepared for this scenario, causing a three-layer failure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;No reconnection after disconnection&lt;/strong&gt;: The &lt;code&gt;didCloseWith&lt;/code&gt; callback only updated the status text on the UI without any reconnection logic.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Silent audio discarding&lt;/strong&gt;: After &lt;code&gt;isConnected = false&lt;/code&gt;, every audio chunk from the microphone was silently consumed by &lt;code&gt;guard isConnected else { return }&lt;/code&gt;, without the user noticing.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Incorrect &lt;code&gt;isRunning&lt;/code&gt; state&lt;/strong&gt;: The "Translating" icon in the main window was still flashing, but in reality, nothing was happening.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Claude Code immediately refactored &lt;code&gt;GeminiLiveConnection.swift&lt;/code&gt;, introducing three lines of defense without changing any external interfaces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Proactive GoAway Signal Detection&lt;/strong&gt;: Parse the &lt;code&gt;goAway&lt;/code&gt; JSON message sent by the server in &lt;code&gt;parseServerResponse()&lt;/code&gt;, and actively reconnect before the connection actually drops to achieve seamless translation continuity.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Exponential Backoff Auto-Reconnection&lt;/strong&gt;: After disconnection, retry at increasing intervals of 2s → 4s → 6s, up to 10 times; the count resets after a successful reconnection, ensuring the next disconnection also has a full set of retry opportunities. The entire process is silent and transparent to the user.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;30-second Ping Keep-Alive&lt;/strong&gt;: Start a &lt;code&gt;Timer&lt;/code&gt; after a successful connection to send a &lt;code&gt;sendPing&lt;/code&gt; to the server every 30 seconds, preventing intermediate network equipment from cutting the connection due to inactivity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the modifications were completed, the developer executed &lt;code&gt;bash build_app.sh&lt;/code&gt; and pushed to GitHub:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt;: Build successful, commit completed, pushed. Real-time translation should now be able to operate continuously for a long time, silently reconnecting automatically when the Session expires without the user noticing an interruption.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  Phase 2: Feature Interview — Five Questions to Lock Down Two Priority Development Directions
&lt;/h1&gt;

&lt;p&gt;With stability resolved, the developer began thinking about how the App could go further:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: Help me research carefully and give me three new feature recommendations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After carefully reading the code, Claude Code first proposed a directional question instead of directly giving a list:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which category of these three new feature directions do you prefer?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;A. Utility / UX&lt;/strong&gt; — Making daily use more convenient (transcript export, hotkeys, meeting notes)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;B. Translation Quality&lt;/strong&gt; — More accurate and natural (multi-language detection, speaker identification)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;C. System Integration&lt;/strong&gt; — Connecting with other tools (Notion, Zoom overlay)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: A&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three UX features were immediately produced: &lt;strong&gt;Floating Subtitle Window&lt;/strong&gt;, &lt;strong&gt;Automatic Meeting Transcript Export&lt;/strong&gt;, and &lt;strong&gt;Global Hotkeys&lt;/strong&gt;. The developer's response was direct:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: I want both 1 and 2.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Next was a brief requirement interview, where Claude Code asked only one most critical question at a time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  How many lines should the floating window display? → &lt;strong&gt;Double lines (small text for original + large text for translation)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  Background style? → &lt;strong&gt;Vibrancy effect (frosted glass)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  Export method? → &lt;strong&gt;Automatically save to the desktop, no dialog box&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After five questions, the design direction was completely clear. Claude Code proposed a complete design plan and wrote the specification document. After the developer confirmed "no problem," it entered the implementation phase.&lt;/p&gt;




&lt;h1&gt;
  
  
  Phase 3: Plan-Driven Development — Subagent Closed-Loop Delivery, Review Catches Critical Bugs
&lt;/h1&gt;

&lt;p&gt;With clear specifications, Claude Code entered its most proficient work mode: &lt;strong&gt;Write a plan first, then use multiple independent Subagents to execute tasks, with each Task immediately reviewed by a Reviewer Subagent upon completion&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The entire process was divided into three Tasks; the two most critical ones are recorded below:&lt;/p&gt;

&lt;h3&gt;
  
  
  Task 1: Automatic Meeting Transcript Export
&lt;/h3&gt;

&lt;p&gt;The Implementer Subagent quickly completed three things: removed the original 25-line history limit, added the &lt;code&gt;exportTranscript()&lt;/code&gt; method, and automatically saved the complete bilingual comparison record in Markdown format to the Desktop when translation stopped.&lt;/p&gt;

&lt;p&gt;However, the &lt;strong&gt;Reviewer Subagent&lt;/strong&gt; immediately raised a flag:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Critical Issue found: &lt;code&gt;status = "Stopped"&lt;/code&gt; in &lt;code&gt;stop()&lt;/code&gt; is executed immediately after &lt;code&gt;exportTranscript()&lt;/code&gt;, instantly overwriting the save path message. The user will only ever see "Stopped" and will never know where the file was saved.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This was a logic bug just one line away, which would have been very easy to overlook without a Reviewer. The &lt;strong&gt;Fix Subagent&lt;/strong&gt; then intervened, changing &lt;code&gt;exportTranscript()&lt;/code&gt; to return a &lt;code&gt;Bool&lt;/code&gt;: when export is successful, &lt;code&gt;stop()&lt;/code&gt; no longer overwrites the status; "Stopped" is only displayed when there are no records to export. After the modification, the Reviewer confirmed again, and all passed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Task 2: Floating Subtitle Window
&lt;/h3&gt;

&lt;p&gt;Added &lt;code&gt;FloatingSubtitleWindow.swift&lt;/code&gt;, with a core structure of three layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;NSPanel&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;level = .floating&lt;/code&gt;): Always on top, does not steal focus (&lt;code&gt;.nonactivatingPanel&lt;/code&gt;), and can be displayed across full-screen Apps.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;NSVisualEffectView&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;material = .hudWindow&lt;/code&gt;): Native macOS vibrancy effect.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;NSHostingView&lt;/code&gt;&lt;/strong&gt; embedding SwiftUI's &lt;code&gt;FloatingSubtitleView&lt;/code&gt;: Directly bound to &lt;code&gt;TranslatorViewModel.currentLine&lt;/code&gt;, updating in real-time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At the same time, ownership of &lt;code&gt;TranslatorViewModel&lt;/code&gt; was moved up from &lt;code&gt;ContentView&lt;/code&gt; to &lt;code&gt;TranslatorApp&lt;/code&gt;, allowing the main window and the floating window to share the same data source, avoiding data duplication or synchronization issues. The window position is saved to &lt;code&gt;UserDefaults&lt;/code&gt; after dragging and automatically restored after a restart.&lt;/p&gt;

&lt;p&gt;The Task Reviewer checked all 11 specifications one by one; all passed without any need for correction.&lt;/p&gt;

&lt;p&gt;The entire "Implementation → Review → Correction → Re-review" closed loop was completed automatically by subagents. The developer only needed to confirm that the final &lt;code&gt;bash build_app.sh&lt;/code&gt; passed cleanly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt;: Build successful, commit completed, pushed.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  Phase 4: App Brand Upgrade — Real-time Generation of Professional Icons with Python
&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%2Fn16axnnwtairp47x2jt7.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%2Fn16axnnwtairp47x2jt7.png" alt="image-20260702135008634" width="516" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With features complete, the developer turned their attention to the appearance:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: The app icon doesn't look good, help me generate a professional one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude Code first confirmed that &lt;code&gt;Pillow&lt;/code&gt; (Python image library) was in the environment, then directly wrote a complete Icon generation script with the following design description:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Background&lt;/strong&gt;: Deep sea blue gradient (&lt;code&gt;#0D1B4E&lt;/code&gt; → &lt;code&gt;#1565C0&lt;/code&gt;), standard macOS 22% rounded corners, echoing the macOS Design Language.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Core Pattern&lt;/strong&gt;: Two overlapping speech bubbles. The upper bubble (semi-transparent white) contains "&lt;strong&gt;A&lt;/strong&gt;" representing the original English audio, and the lower bubble (pure white) contains "&lt;strong&gt;中&lt;/strong&gt;" representing the translated output. They are connected by a bidirectional arrow in the center, making the "real-time translation" product positioning clear at a glance.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Fonts&lt;/strong&gt;: Avenir Next for English and Apple SD Gothic Neo for Chinese, both of which are built-in macOS fonts, requiring no external resources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script output 10 sizes at once (16px → 1024px), converted them into an &lt;code&gt;.icns&lt;/code&gt; file using the system's &lt;code&gt;iconutil&lt;/code&gt; command, and automatically updated &lt;code&gt;build_app.sh&lt;/code&gt; to copy the icon into the App Bundle, adding the &lt;code&gt;CFBundleIconFile&lt;/code&gt; declaration to Info.plist. The entire process did not require opening Xcode or using any image design tools.&lt;/p&gt;




&lt;h1&gt;
  
  
  Phase 5: Code Quality Refinement — Clearing All Compilation Warnings
&lt;/h1&gt;

&lt;p&gt;When the developer executed &lt;code&gt;bash build_app.sh&lt;/code&gt; for acceptance, they noticed a few lines of yellow warnings in the output:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: There are some warnings when running build_app.sh, help me check them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude Code carefully executed the Build and categorized three types of warnings, treating them accordingly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Warning Type&lt;/th&gt;
&lt;th&gt;Root Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;onChange(of:perform:)&lt;/code&gt; deprecated × 2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;swiftc&lt;/code&gt; did not specify a deployment target, defaulting to the latest SDK rules&lt;/td&gt;
&lt;td&gt;Added &lt;code&gt;-target arm64-apple-macos13.0&lt;/code&gt; to &lt;code&gt;build_app.sh&lt;/code&gt; to let the compiler know we are targeting macOS 13, where the old API is the correct choice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SCRunningApplication&lt;/code&gt; non-Sendable × 2&lt;/td&gt;
&lt;td&gt;Types in the ScreenCaptureKit framework are not marked &lt;code&gt;Sendable&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Changed &lt;code&gt;import ScreenCaptureKit&lt;/code&gt; to &lt;code&gt;@preconcurrency import ScreenCaptureKit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;TranslatorViewModel&lt;/code&gt; non-Sendable captured&lt;/td&gt;
&lt;td&gt;ViewModel was captured in a &lt;code&gt;@Sendable&lt;/code&gt; closure&lt;/td&gt;
&lt;td&gt;Added &lt;code&gt;@MainActor&lt;/code&gt; to &lt;code&gt;TranslatorViewModel&lt;/code&gt; (modern standard practice for SwiftUI ViewModels) and added &lt;code&gt;@preconcurrency&lt;/code&gt; to delegate conformance to suppress derived warnings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The final Build output was as clean as new, without any Warnings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🛠 Starting compilation of Swift files (target: arm64-apple-macos13.0)...
🎨 Copying App Icon...
📝 Generating Info.plist...
✅ Packaging complete!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All modifications were committed and pushed to GitHub together.&lt;/p&gt;




&lt;h1&gt;
  
  
  Phase 6: Real-World Pitfalls — The ScreenCaptureKit Permission Labyrinth
&lt;/h1&gt;

&lt;p&gt;The App's functionality seemed complete until the developer actually turned it on to start using it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: Is it a permission issue? I open the app and it keeps failing to scan the "Target App" list. Help me check the relevant code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The App list was always empty. "Screen Recording" in System Settings was indeed checked. This is a typical "permissions are granted, but it just doesn't work" dead-end problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  First Cut: Handling Silent Failures
&lt;/h3&gt;

&lt;p&gt;After reading &lt;code&gt;AudioCaptureManager.swift&lt;/code&gt;, Claude Code immediately spotted the problem: when the &lt;code&gt;fetchShareableApps()&lt;/code&gt; call to &lt;code&gt;SCShareableContent.current&lt;/code&gt; failed, it only &lt;code&gt;print&lt;/code&gt;ed to the console. The UI showed an empty list without any prompt. The developer had no idea what was happening.&lt;/p&gt;

&lt;p&gt;The first wave of modifications did three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Added &lt;code&gt;NSScreenCaptureUsageDescription&lt;/code&gt; to &lt;code&gt;Info.plist&lt;/code&gt;&lt;/strong&gt;: Without this key, the macOS authorization dialog will never pop up.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Added an ad-hoc signing step&lt;/strong&gt;: &lt;code&gt;codesign --sign - --force --deep&lt;/code&gt; — ScreenCaptureKit requires the App to have a code identity to appear in the "System Settings &amp;gt; Screen Recording" list.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Surfaced errors to the UI&lt;/strong&gt;: Changed &lt;code&gt;fetchShareableApps()&lt;/code&gt; to return &lt;code&gt;(apps, errorMessage?)&lt;/code&gt;. Any failure would be displayed in the App's status bar, allowing the developer to see immediately what happened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build completed, tested again—still the same error message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Second Cut: Overly Aggressive Error Classification Logic
&lt;/h3&gt;

&lt;p&gt;Looking closely at the error judgment code:&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;isPermissionDenied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nsError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;nsError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&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;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localizedDescription&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lowercased&lt;/span&gt;&lt;span class="p"&gt;()&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;"permission"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localizedDescription&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lowercased&lt;/span&gt;&lt;span class="p"&gt;()&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;"denied"&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;contains("permission")&lt;/code&gt; line was too aggressive. As long as any word containing "permission" appeared in the error description, it would be incorrectly judged as "Permission Denied," displaying "Please go to System Settings to enable authorization." In reality, it could be a completely different error.&lt;/p&gt;

&lt;p&gt;Claude Code corrected the judgment logic—only the exact ScreenCaptureKit &lt;code&gt;userDeclined&lt;/code&gt; error code (&lt;code&gt;-3801&lt;/code&gt;) is treated as a permission issue. All other errors display the actual domain, code, and description for easier diagnosis:&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;isPermissionDenied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nsError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3801&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isPermissionDenied&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"Screen recording permission required: Please go to System Settings to enable authorization"&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Unable to get App list (code &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;nsError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&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;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localizedDescription&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Third Cut: Finding the Root Cause — TCC Identity Mismatch
&lt;/h3&gt;

&lt;p&gt;After correcting the error classification, Claude Code ran the App and captured the logs, finding that the status bar displayed a new message with a code number, not &lt;code&gt;-3801&lt;/code&gt;. This confirmed: &lt;strong&gt;The problem wasn't that the user hadn't given permission, but that macOS didn't recognize the App at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The root cause:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every time &lt;code&gt;build_app.sh&lt;/code&gt; is executed and re-signed ad-hoc, the hash of the binary changes, and the macOS TCC database treats it as a completely new App.&lt;/strong&gt; The old screen recording authorization was given to the previous binary; the new binary did not inherit it. System Settings shows it as checked, but that's authorization for the old identity, which is invalid for the new binary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The solution is to reset TCC to let macOS re-trigger the authorization dialog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tccutil reset ScreenCapture com.poc.MeetingTranslator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After execution, reopening the App and clicking "↻" caused macOS to immediately pop up the "MeetingTranslator wants to record the contents of this screen" dialog. Clicking "Allow" instantly listed all running applications in the App list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Permanent Countermeasure: Writing the Reset into the Build Process
&lt;/h3&gt;

&lt;p&gt;The ad-hoc signing issue persists during development—every rebuild requires re-authorization. Claude Code added &lt;code&gt;tccutil reset&lt;/code&gt; directly as the last step of &lt;code&gt;build_app.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tccutil reset ScreenCapture com.poc.MeetingTranslator 2&amp;gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✅ Reset complete. The system will ask for authorization again after opening the App"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From then on, after every &lt;code&gt;bash build_app.sh&lt;/code&gt;, simply &lt;code&gt;open MeetingTranslator.app&lt;/code&gt;, and the system will ask for authorization again. The entire development cycle will never again get stuck in the "permissions are granted but it doesn't work" loop.&lt;/p&gt;




&lt;h1&gt;
  
  
  Conclusion: The True Value of the "Plan → Subagent Implementation → AI Review" Closed Loop
&lt;/h1&gt;

&lt;p&gt;This collaboration with Claude Code made me feel a completely different way of working compared to the first AGY CLI development:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Proactively asking questions instead of just starting&lt;/strong&gt;: Faced with "give me three new feature recommendations," Claude Code's first step was to ask for a direction; faced with the "floating window," it confirmed style and details one by one. This rhythm of "align first, then implement" is much more reliable than directly guessing requirements.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The plan is a moat for quality&lt;/strong&gt;: Writing specification documents and implementation plans before implementation gives each Subagent clear boundaries and acceptance criteria. This seemingly "redundant" step directly discovered a state-overwriting bug in the Task 1 review that human developers could easily overlook.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI Reviewing AI is a different layer of protection&lt;/strong&gt;: The Reviewer Subagent and Implementer Subagent are started completely independently; they share no context. Because of this, the Reviewer can discover the Implementer's blind spots from a fresh perspective—this is the extra protection brought by "AI double-checking," not a replacement for human Code Review, but a completely new level of quality.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tool boundaries are functional boundaries&lt;/strong&gt;: App Icon generation, Warning fixes, Git commit/push—Claude Code moves freely throughout the development environment. The developer doesn't need to switch tools; all actions are completed within the dialogue.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Real-world use is the best test&lt;/strong&gt;: The ScreenCaptureKit issue in Phase 6 never appeared in any build tests until the developer actually turned it on to use it. This kind of "silent failure + system-level identity mismatch" problem only surfaces in real-world scenarios. Claude Code's diagnostic method—from correcting error classification to letting the UI display real error codes, to finding the TCC root cause—is a typical "narrowing the hypothesis range and letting the problem speak" debugging approach.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the first article was about "from zero to one," this record is about "from usable to good, and then to standing firm in real-world scenarios." Two types of AI Agents, two collaboration styles, together completed a full Native macOS App covering low-level audio, WebSocket connections, SwiftUI UI, Python image generation, and system permission diagnosis. See you next time!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>programming</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>[Gemini API in Action] Building MemeFinder: A Native Mac Menu Bar Widget for Finding Memes via Text Using Gemini Vision &amp; Semantic Embeddings</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Mon, 22 Jun 2026 00:41:17 +0000</pubDate>
      <link>https://dev.to/gde/gemini-api-hands-on-59dc</link>
      <guid>https://dev.to/gde/gemini-api-hands-on-59dc</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%2Fhh50l2jdcckul8cmidwl.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%2Fhh50l2jdcckul8cmidwl.png" alt="image-memefinder-hero" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  The Origin: Mid-Conversation, Where on Earth Is That Meme?
&lt;/h1&gt;

&lt;p&gt;Anyone who chats a lot has a folder full of memes on their phone and computer, but the moment you actually need one — the conversation is rolling, you want to drop a "thanks but no thanks" or an "I'm trash" reaction — you can't find it. The filename is &lt;code&gt;IMG_4821.jpg&lt;/code&gt;, the photo library has no categories, and search is a non-starter.&lt;/p&gt;

&lt;p&gt;I first came across a wonderful open-source project, &lt;a href="https://github.com/ShiQu1218/MemeTalk" rel="noopener noreferrer"&gt;ShiQu1218/MemeTalk&lt;/a&gt;. It builds a local meme semantic-search system with Python + Streamlit + SQLite: it scans your local meme folder, indexes images with OCR and vector embeddings, then does multi-route retrieval. Feature-complete, but research-oriented and requires opening a browser to run Streamlit.&lt;/p&gt;

&lt;p&gt;What I wanted was something closer to an "everyday handy tool":&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A native Mac app, one search box. I type what I'm looking for and the relevant meme pops up. Click it and it's copied straight to the clipboard.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So MemeFinder was born. This post records its journey from zero to "menu-bar resident + global hotkey," and several representative pitfalls along the way.&lt;/p&gt;




&lt;h1&gt;
  
  
  System Design and Architecture
&lt;/h1&gt;

&lt;p&gt;The core concept is simple: &lt;strong&gt;point at a local meme folder → have Gemini build an index for each image → type to do a semantic search → click to copy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I made three key technical decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native SwiftUI app&lt;/strong&gt;, not Electron. Copying images to the clipboard, global hotkeys, menu-bar residency — with AppKit these are all first-class citizens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini&lt;/strong&gt; does two things: the vision model &lt;code&gt;gemini-3-flash-preview&lt;/code&gt; reads the text in each image and generates a Traditional Chinese description plus emotion tags; &lt;code&gt;gemini-embedding-2&lt;/code&gt; turns that semantics into a 768-dimensional vector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid semantic-vector + keyword search.&lt;/strong&gt; Pure keyword recall for Chinese is too poor; only semantic vectors achieve "type a related description and find the image."&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  System Architecture Flow
&lt;/h3&gt;

&lt;p&gt;The project is deliberately split into two Swift Package targets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Contents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MemeFinder&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;library&lt;/td&gt;
&lt;td&gt;Logic, models, services, ViewModels (all unit-tested)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MemeFinderApp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;executable&lt;/td&gt;
&lt;td&gt;SwiftUI views + menu-bar shell (thin layer, depends on the library)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This split isn't decorative — it directly determines whether the tests can run smoothly, as "Pitfall #2" will explain.&lt;/p&gt;




&lt;h1&gt;
  
  
  Core Implementation
&lt;/h1&gt;

&lt;h3&gt;
  
  
  1. Auto-tagging memes with the Gemini vision model
&lt;/h3&gt;

&lt;p&gt;During indexing, each image is sent to the vision model with a request to &lt;strong&gt;output only JSON&lt;/strong&gt;: the text in the image, a Traditional Chinese description, tags, and emotion. &lt;code&gt;responseMimeType&lt;/code&gt; is set to &lt;code&gt;application/json&lt;/code&gt; to keep the output format stable:&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;public&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;annotateRequest&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="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;imageData&lt;/span&gt;&lt;span class="p"&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;mimeType&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;URLRequest&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;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""
    你是迷因圖標註助手。請閱讀這張圖，輸出 JSON，欄位：
    ocr_text(圖中所有文字), description(用繁體中文描述畫面與梗),
    tags(3-8 個繁體中文關鍵字陣列), emotion(單一情緒詞)。只輸出 JSON。
    """&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;body&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;Any&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="s"&gt;"contents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;
            &lt;span class="s"&gt;"parts"&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="s"&gt;"text"&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="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"inline_data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"mime_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;imageData&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;base64EncodedString&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="s"&gt;"generationConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"responseMimeType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;// ... set URL, x-goog-api-key header, POST body&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Hybrid semantic + keyword ranking
&lt;/h3&gt;

&lt;p&gt;After the query string is embedded into a vector, we compute cosine similarity for every image, then add weight for keywords that hit the OCR text and tags, and merge-sort:&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;public&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;queryEmbedding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;queryText&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;in&lt;/span&gt; &lt;span class="nv"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;IndexedImage&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;limit&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;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;SearchResult&lt;/span&gt;&lt;span class="p"&gt;]&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;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queryText&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lowercased&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;whereSeparator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isWhitespace&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&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="o"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;init&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;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;SearchResult&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compactMap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;cos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosineSimilarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryEmbedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&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;haystack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ocrText&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;separator&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="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lowercased&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;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;haystack&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="nv"&gt;$0&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;count&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;boost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// keyword boost capped at 0.3&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;boost&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;SearchResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&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="nv"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sorted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefix&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 whole search engine is a pure function, with Gemini hidden behind a protocol, so this logic can be fully unit-tested offline without hitting the real API.&lt;/p&gt;




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

&lt;p&gt;The real time sink in this project was never the happy path — it was the pitfalls below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall #1: The mysterious &lt;code&gt;GeminiError error 0&lt;/code&gt; — indexing and search both fail
&lt;/h3&gt;

&lt;p&gt;App packaged, key set, folder chosen, hit search — and nothing shows below, just &lt;code&gt;GeminiError error 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Rather than guessing, I hit the embedding endpoint once with a real key and printed the response:&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="s2"&gt;"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2:embedContent"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-goog-api-key: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&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;'{"content":{"parts":[{"text":"貓"}]},"output_dimensionality":768}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The evidence was unmistakable:&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;"embedding"&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;"values"&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="mf"&gt;-0.0063&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-0.0200&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="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 problem: my parser was reading the &lt;strong&gt;plural&lt;/strong&gt; &lt;code&gt;embeddings[0].values&lt;/code&gt; (that's the &lt;code&gt;batchEmbedContents&lt;/code&gt; batch-endpoint format), but the single &lt;code&gt;embedContent&lt;/code&gt; call returns the &lt;strong&gt;singular&lt;/strong&gt; &lt;code&gt;embedding.values&lt;/code&gt;. So &lt;strong&gt;every embed call failed&lt;/strong&gt; — indexing each image failed, embedding the query string failed — all throwing &lt;code&gt;badResponse&lt;/code&gt; (shown in the UI as &lt;code&gt;GeminiError error 0&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[Solution]&lt;/strong&gt;&lt;br&gt;
Fix the parser to read the singular &lt;code&gt;embedding.values&lt;/code&gt;, keeping the plural format as a fallback; I also hardened the annotation parser (a thinking model sometimes returns a textless "thought" part first, so skip to the first part that actually has text):&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;public&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;embedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fromEmbedContent&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&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;Float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;JSONSerialization&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jsonObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;with&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;span class="k"&gt;as?&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;Any&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="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;GeminiError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;badResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cannot parse embedContent payload"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// A single embedContent returns {"embedding":{"values":[...]}}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"embedding"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as?&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;Any&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;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Double&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;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Float&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;init&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// batchEmbedContents is {"embeddings":[{"values":[...]}]} — tolerate it too&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"embeddings"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as?&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;Any&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;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;?[&lt;/span&gt;&lt;span class="s"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Double&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;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Float&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;init&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;GeminiError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;badResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cannot parse embedContent payload"&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;Lesson: &lt;strong&gt;trust the actual API response over your memory or secondhand docs.&lt;/strong&gt; A single line of &lt;code&gt;curl&lt;/code&gt; saved countless guesses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall #2: SwiftPM's &lt;code&gt;main&lt;/code&gt; entry-point conflict and the SwiftUICore linking error
&lt;/h3&gt;

&lt;p&gt;I initially made the whole project a single &lt;code&gt;executableTarget&lt;/code&gt; with the tests depending on it directly. The result: tests failed to link no matter what. An executable target needs a &lt;code&gt;main&lt;/code&gt; entry point, but that entry point only exists at the UI step's &lt;code&gt;@main App&lt;/code&gt;; and casually adding a placeholder &lt;code&gt;main.swift&lt;/code&gt; then conflicts with &lt;code&gt;@main&lt;/code&gt; (Swift doesn't allow two entry points in one target). Worse, SwiftUI in an executable target spews &lt;code&gt;SwiftUICore.tbd ... not an allowed client&lt;/code&gt; linker warnings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[Root cause analysis and solution]&lt;/strong&gt;&lt;br&gt;
This is actually an architecture problem, not a compilation problem. The right approach is to split the project into two layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MemeFinder&lt;/code&gt; (library target)&lt;/strong&gt;: all logic, models, services, ViewModels — the tests depend only on this layer, it has no entry point, and it links cleanly as a library. ViewModels &lt;code&gt;import Combine&lt;/code&gt; (not SwiftUI) to get &lt;code&gt;ObservableObject&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MemeFinderApp&lt;/code&gt; (executable target)&lt;/strong&gt;: only SwiftUI views and &lt;code&gt;@main&lt;/code&gt;, with &lt;code&gt;import MemeFinder&lt;/code&gt; to use the public types above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the split, the library and tests don't touch SwiftUI at all, the linker warnings disappear, and the &lt;code&gt;@main&lt;/code&gt; conflict no longer exists. &lt;strong&gt;"What the tests need to depend on" often forces out clean module boundaries.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Pitfall #3: Parallel indexing's rate limit and "I want to stop indexing halfway"
&lt;/h3&gt;

&lt;p&gt;The first version indexed one image at a time, serially calling Gemini (annotate then embed). For hundreds of images this was painfully slow. So I switched to bounded parallelism with &lt;code&gt;withTaskGroup&lt;/code&gt; (at most 4 at once), which brought three new problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Gemini free tier has a &lt;strong&gt;rate limit&lt;/strong&gt; — too much concurrency triggers 429.&lt;/li&gt;
&lt;li&gt;The user wants to &lt;strong&gt;cancel&lt;/strong&gt; halfway through a large folder.&lt;/li&gt;
&lt;li&gt;Parallel completion order is chaotic, but the results need &lt;strong&gt;stable sorting&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;[Solution]&lt;/strong&gt;&lt;br&gt;
Handle the three problems separately, all converging in the same &lt;code&gt;buildIndex&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;429 backoff retry&lt;/strong&gt;: retry only &lt;code&gt;GeminiError.rateLimited&lt;/code&gt; with exponential backoff (max 3 attempts); other errors are recorded without retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cooperative cancellation&lt;/strong&gt;: honor &lt;code&gt;Task.isCancelled&lt;/code&gt;; on cancel, stop scheduling new work and keep the completed portion. Even the backoff &lt;code&gt;Task.sleep&lt;/code&gt; lets &lt;code&gt;CancellationError&lt;/code&gt; propagate normally instead of swallowing it and firing one more API call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable sorting&lt;/strong&gt;: collect results into a &lt;code&gt;[path: image]&lt;/code&gt; dictionary, then reassemble the output in the order of the pre-sorted file list, decoupled from completion order.
&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;// Seed maxConcurrent tasks first, then refill one per completion — strictly cap concurrency&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&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;maxConcurrent&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="nf"&gt;scheduleNext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;res&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;group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&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;resultsByPath&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;res&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="n"&gt;errors&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;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nf"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&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="nf"&gt;scheduleNext&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;Incidentally, the HTTP status code was also extracted into a pure function &lt;code&gt;mapResponse(data:statusCode:)&lt;/code&gt;: 429 → &lt;code&gt;rateLimited&lt;/code&gt;, other non-2xx → &lt;code&gt;httpError(code)&lt;/code&gt;, 2xx → return the data. The retry logic then has a basis, and this part is easy to test too.&lt;/p&gt;
&lt;h3&gt;
  
  
  Pitfall #4: Evolving from a "windowed app" into "menu-bar resident + global hotkey"
&lt;/h3&gt;

&lt;p&gt;Whether a tool is pleasant to use comes down to "how many steps to summon it." I wanted to hit &lt;strong&gt;⌃⌘M&lt;/strong&gt; mid-conversation to bring up the search popover, with the app tucked into the menu bar, not occupying the Dock. This step hit two classic macOS pitfalls:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(a) Does a global hotkey need accessibility permission?&lt;/strong&gt; No. Use Carbon's &lt;code&gt;RegisterEventHotKey&lt;/code&gt; to register a fixed hotkey, which doesn't need Accessibility permission (unlike monitoring the whole keyboard). But under Swift 6 strict concurrency, the C event callback has to dispatch through a static &lt;code&gt;id → instance&lt;/code&gt; registry, requiring &lt;code&gt;nonisolated(unsafe)&lt;/code&gt; and relying on the invariant that "Carbon events are delivered on the main thread" for safety. If ⌃⌘M is already taken, &lt;code&gt;RegisterEventHotKey&lt;/code&gt; returns failure — in which case we silently degrade, log a line, and the menu-bar icon still works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(b) The timing race in the menu-bar right-click menu.&lt;/strong&gt; The initial approach was "set &lt;code&gt;statusItem.menu&lt;/code&gt; → &lt;code&gt;performClick&lt;/code&gt; → immediately clear &lt;code&gt;menu&lt;/code&gt;," but clearing synchronously fights AppKit's menu-tracking loop, and the menu flashes and disappears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[Solution]&lt;/strong&gt;&lt;br&gt;
Pop the menu up directly, fully bypassing the assign-and-clear of &lt;code&gt;statusItem.menu&lt;/code&gt;:&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;@objc&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;statusButtonClicked&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSApp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currentEvent&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;togglePopover&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rightMouseUp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Pop up directly; don't assign then synchronously clear statusItem.menu&lt;/span&gt;
        &lt;span class="c1"&gt;// (it races AppKit's menu-tracking loop)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusItem&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;NSMenu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;popUpContextMenu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;makeMenu&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;with&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="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;button&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;togglePopover&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;Finally, adding &lt;code&gt;LSUIElement = true&lt;/code&gt; to the &lt;code&gt;Info.plist&lt;/code&gt; produced by &lt;code&gt;build-app.sh&lt;/code&gt; makes the Dock icon disappear, and MemeFinder officially becomes a pure menu-bar tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall #5: The settings form is blank — one symptom, three layers of cause
&lt;/h3&gt;

&lt;p&gt;After moving to the menu-bar version, a user reported "the settings window is completely blank." This seemingly simple bug, peeled apart, actually had three layers, each highly representative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: a &lt;code&gt;Form&lt;/code&gt; collapses to zero height inside a hand-rolled &lt;code&gt;NSWindow&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
Originally the settings screen lived in SwiftUI's native &lt;code&gt;Settings { }&lt;/code&gt; scene, which sizes it sensibly. After the refactor it was hosted in a hand-rolled &lt;code&gt;NSWindow(contentViewController: NSHostingController(rootView: SettingsView()))&lt;/code&gt;, and &lt;code&gt;SettingsView&lt;/code&gt; ended with only &lt;code&gt;.frame(width: 460)&lt;/code&gt; — &lt;strong&gt;width only, no height&lt;/strong&gt;. &lt;code&gt;NSWindow(contentViewController:)&lt;/code&gt; sizes the window from the content's natural size, but a SwiftUI &lt;code&gt;Form&lt;/code&gt; is vertically greedy; with no constraint, its natural height resolves to nearly 0, so the window opens as a 460-wide, near-zero-height blank strip. The fix is just to add a height:&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="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// When hosted in a hand-rolled NSWindow (not a SwiftUI Settings scene), a Form&lt;/span&gt;
&lt;span class="c1"&gt;// with no height constraint collapses to ~0, turning the window into a blank strip.&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;460&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;320&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;Layer 2: ⌘, and the menu-bar "Settings…" go down two different paths.&lt;/strong&gt;&lt;br&gt;
After adding the height, the user said "still blank." On follow-up I found out he was summoning settings with &lt;strong&gt;⌘,&lt;/strong&gt;, while the menu-bar right-click "Settings…" went down a different path. The reason: ⌘, in a SwiftUI app triggers the &lt;code&gt;Settings { }&lt;/code&gt; scene, and to dodge a state-sharing problem during the refactor, I had set that to &lt;code&gt;Settings { EmptyView() }&lt;/code&gt;:&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;// During the refactor, the Settings scene was left empty to avoid state-sharing&lt;/span&gt;
&lt;span class="c1"&gt;// — so ⌘, opens a blank window&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;Scene&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Settings&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;EmptyView&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;In other words, &lt;strong&gt;settings had two entry points pointing at different things&lt;/strong&gt;: ⌘, pointed at the empty scene, the menu-bar "Settings…" pointed at the real window. The fix unifies the two paths — let the &lt;code&gt;Settings&lt;/code&gt; scene host the real &lt;code&gt;SettingsView&lt;/code&gt; (so ⌘, works directly), and make the menu-bar "Settings…" open the same native settings window too:&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;Settings&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;SettingsView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;appDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;indexing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;appDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="nv"&gt;onReindex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;appDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reindexNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                 &lt;span class="nv"&gt;onCancel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;appDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelReindex&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The menu-bar "Settings…" now opens the same Settings scene&lt;/span&gt;
&lt;span class="kd"&gt;@objc&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;openSettings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;NSApp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;ignoringOtherApps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;NSApp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Selector&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="s"&gt;"showSettingsWindow:"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&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 also leverages the fact that a SwiftUI App body is &lt;code&gt;@MainActor&lt;/code&gt;-isolated — so reading the &lt;code&gt;@MainActor&lt;/code&gt; &lt;code&gt;appDelegate.settings&lt;/code&gt; directly from the body is legal, with no extra bridging needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 (the most insidious): &lt;code&gt;open&lt;/code&gt; doesn't reload a menu-bar app at all.&lt;/strong&gt;&lt;br&gt;
The biggest time-waster in the process was that after recompiling, I'd ask the user to &lt;code&gt;open MemeFinder.app&lt;/code&gt;, yet he kept seeing the old behavior. Because MemeFinder is an &lt;code&gt;LSUIElement&lt;/code&gt; menu-bar-resident app — when an instance is already running, &lt;code&gt;open&lt;/code&gt; only &lt;strong&gt;wakes the existing old process&lt;/strong&gt; instead of relaunching with the new binary. So we were actually testing the same old build the whole time. The correct dev loop is to truly kill it first, then run from source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;killall MemeFinderApp 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; swift run MemeFinderApp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This layer reminds me: &lt;strong&gt;when debugging, first confirm "what you're testing really is the version you changed"&lt;/strong&gt; — otherwise all your reasoning is built on faulty observations.&lt;/p&gt;




&lt;h1&gt;
  
  
  On the "Development Process" Itself
&lt;/h1&gt;

&lt;p&gt;This project was driven almost entirely by an AI agent workflow of &lt;strong&gt;spec → plan → subagent task-by-task implementation → two-stage review&lt;/strong&gt;: each feature started with a design spec, was broken into independently testable small tasks, every task wrote a failing test first (TDD) before implementing, and after completion an independent review agent checked spec compliance and code quality, followed by one final whole-branch review.&lt;/p&gt;

&lt;p&gt;Several of the pitfalls — &lt;code&gt;GeminiError error 0&lt;/code&gt;, the library/executable split, swallowing &lt;code&gt;CancellationError&lt;/code&gt; during backoff, the menu timing race — were in fact caught half the time during the &lt;strong&gt;review stage&lt;/strong&gt;, not written correctly on the first pass. This echoes that old principle: &lt;strong&gt;having tests as armor, and someone (or an agent) seriously reading the diff, matters far more than writing fast.&lt;/strong&gt; The final project maintains 47 unit tests and a zero-warning release build.&lt;/p&gt;




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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Type to find, click to paste&lt;/strong&gt;: type a Chinese description in the menu-bar popover, semantic search instantly lists relevant memes, click one to copy it to the clipboard and paste straight into LINE / Slack / Messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy-friendly, searchable offline&lt;/strong&gt;: images and the index live locally (&lt;code&gt;~/Library/Application Support/MemeFinder/index.json&lt;/code&gt;); only the "build the index" step calls Gemini.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A truly handy tool&lt;/strong&gt;: ⌃⌘M is available anytime, menu-bar resident, no Dock footprint; incremental indexing only processes new/changed images, and indexing can show progress and be canceled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A clean, maintainable architecture&lt;/strong&gt;: a two-layer library/executable design, Gemini hidden behind a protocol, pure logic fully covered by tests.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All the development code for this project is open-sourced on GitHub: &lt;a href="https://github.com/kkdai/meme-finder-app" rel="noopener noreferrer"&gt;kkdai/meme-finder-app&lt;/a&gt;. Feel free to clone it, point it at your own meme-collection folder, and experience the joy of "type to find your meme"!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>gemini</category>
      <category>python</category>
    </item>
    <item>
      <title>[Gemini API] Gemini Batch API and Webhook API practical usage on restaurant survey</title>
      <dc:creator>Evan Lin</dc:creator>
      <pubDate>Mon, 15 Jun 2026 04:09:16 +0000</pubDate>
      <link>https://dev.to/gde/gemini-api-hands-on-6im</link>
      <guid>https://dev.to/gde/gemini-api-hands-on-6im</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.amazonaws.com%2Fuploads%2Farticles%2F2xmga58mup383o4go36l.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.amazonaws.com%2Fuploads%2Farticles%2F2xmga58mup383o4go36l.png" alt="image-20260614175257527" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  A Powerful Tool for Asynchronous Processing: Gemini Batch API &amp;amp; Webhooks
&lt;/h1&gt;

&lt;p&gt;When developing LLM-based applications, we often need to handle a large number of data analysis tasks—for example, analyzing reviews from dozens of restaurants at once, classifying a large volume of articles, or batch generating translations. If we use traditional synchronous APIs (real-time calls), we would not only face severe &lt;strong&gt;Rate Limit&lt;/strong&gt; blockages but also fail due to network connection timeouts and extremely high computing costs.&lt;/p&gt;

&lt;p&gt;To overcome this limitation, Google has launched the &lt;strong&gt;Gemini Batch API&lt;/strong&gt; and &lt;strong&gt;Webhook API&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ai.google.dev/gemini-api/docs/batch-api?hl=zh-tw" rel="noopener noreferrer"&gt;Gemini Batch API&lt;/a&gt;&lt;/strong&gt;: Allows developers to package a large number of requests into a JSONL file and upload them all at once. Gemini performs asynchronous scheduled computations in the background, without consuming your daily real-time API quotas (Rate Limits), and its computing cost is usually half that of real-time APIs, making it a perfect choice for non-urgent big data processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ai.google.dev/gemini-api/docs/webhooks?hl=zh-tw" rel="noopener noreferrer"&gt;Webhook API&lt;/a&gt;&lt;/strong&gt;: Traditional Batch tasks require us to constantly write polling logic locally to check the status. With Webhooks, when Gemini completes a Batch computation, it actively sends an HTTP POST callback to your specified URL, instantly notifying you that the task is complete, making the system architecture more elegant and energy-efficient.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article will document how we integrated these two powerful APIs into our &lt;strong&gt;LINE Bot Restaurant Analysis Assistant&lt;/strong&gt; to achieve one-click deep review and signature dish big data analysis for specific restaurants on mobile devices.&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%2Fwww.evanlin.com%2Fimages%2FLINE%25202026-06-14%252017.30.21.tiff" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.evanlin.com%2Fimages%2FLINE%25202026-06-14%252017.30.21.tiff" alt="LINE 2026-06-14 17.30.21" width="800" height="1739"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  System Design and Optimized Architecture
&lt;/h1&gt;

&lt;p&gt;Originally, the restaurant analysis function worked by having the Bot list nearby restaurants when a user sent their location, and then providing a generic "Deep Review Analysis (Batch)" button. Clicking it would send all nearby restaurants for analysis at once. However, this led to a poor UX: analyzing all restaurants took too long, and users often only wanted to delve into &lt;strong&gt;one specific restaurant&lt;/strong&gt; they were interested in.&lt;/p&gt;

&lt;p&gt;Therefore, we optimized the function into &lt;strong&gt;dynamic Quick Reply buttons&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user sends their location, and the Bot searches for nearby restaurants via Google Maps Grounding.&lt;/li&gt;
&lt;li&gt;After the client receives a plain text list of restaurants, the Bot automatically uses Gemini to extract the top 3 highest-rated restaurant names.&lt;/li&gt;
&lt;li&gt;Three customized Quick Reply buttons are generated (e.g., &lt;code&gt;🍴 Analyze Din Tai Fung&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;After the user clicks a specific restaurant button, the Bot immediately replies "Processing" to avoid LINE timeouts, and submits the Batch task for that single restaurant in the background. Once Gemini completes the computation, it proactively pushes a dedicated big data report.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  System Architecture Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    A[User Sends Location] --&amp;gt;|Location Message| B[Google Maps Grounding Search]
    B --&amp;gt;|Plain Text Restaurant List| C[Gemini-2.5-flash Extracts Top 3 Restaurants]
    C --&amp;gt;|Dynamically Generates Quick Reply| D[LINE Bot Replies with 3 Customized Analysis Buttons]
    D --&amp;gt;|User Clicks Specific Analysis| E[FastAPI Background Task]
    E --&amp;gt;|Immediate Reply ACK| F[LINE Chat Message]
    E --&amp;gt;|Package JSONL and Upload| G[Gemini Batch API Submission]
    G --&amp;gt;|Computation Complete Webhook/Polling Callback| H[Proactively Pushes Deep Report to User]

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

&lt;/div&gt;






&lt;h1&gt;
  
  
  Core Implementation
&lt;/h1&gt;

&lt;h3&gt;
  
  
  1. Precisely Extracting Restaurant Names from Grounding Text using Gemini
&lt;/h3&gt;

&lt;p&gt;In &lt;a&gt;tools/maps_tool.py&lt;/a&gt;, the map search returns a plain text string rich in formatting and descriptions. We use Gemini-2.5-flash's structured output concept to precisely extract restaurant names in JSON format:&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;# Extract top three restaurant names for Quick Reply
&lt;/span&gt;        &lt;span class="n"&gt;names&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;place_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;restaurant&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;extract_prompt&lt;/span&gt; &lt;span class="o"&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;Please extract all restaurant names from the following text and return them in a JSON array format (e.g., [&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;Restaurant A&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="s"&gt;Restaurant B&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;]). Please output the JSON array directly, without any markdown tags (like ```
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;endraw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
json) or explanatory text.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="n"&gt;extract_res&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;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-2.5-flash&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;extract_prompt&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;extract_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extract_res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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;extract_res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&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;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extract_text&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="p"&gt;:&lt;/span&gt;
                    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
                    &lt;span class="n"&gt;array_match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\[(.*?)\]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOTALL&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;array_match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;
                        &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal_eval&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;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;array_match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

                &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&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;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&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;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&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;Extracted restaurant names for Quick Reply: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;names&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;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_extract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&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 restaurant names: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e_extract&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="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;raw&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;h3&gt;
  
  
  2. Dynamically Generating LINE Quick Reply Buttons
&lt;/h3&gt;

&lt;p&gt;In &lt;a&gt;main.py&lt;/a&gt;, after obtaining the restaurant list, we dynamically generate &lt;code&gt;QuickReplyButton&lt;/code&gt;. We need to pay special attention to LINE API's length limit for button &lt;code&gt;label&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
python
        quick_reply = None
        if place_type == "restaurant" and result.get("status") == "success":
            restaurant_names = result.get("restaurant_names", [])
            if restaurant_names:
                buttons = []
                for name in restaurant_names[:3]:
                    clean_label = name
                    # LINE label limit is 20 characters
                    if len(clean_label) &amp;gt; 10:
                        clean_label = clean_label[:9] + "…"
                    buttons.append(
                        QuickReplyButton(
                            action=PostbackAction(
                                label=f"🍴 分析 {clean_label}",
                                data=json.dumps({
                                    "action": "specific_foodie_deep_analysis",
                                    "restaurant_name": name
                                }),
                                display_text=f"🔍 進行「{name}」深度評論與招牌菜色分析"
                            )
                        )
                    )
                quick_reply = QuickReply(items=buttons)



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

&lt;/div&gt;




&lt;h1&gt;
  
  
  Major Pitfalls and Solutions
&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.amazonaws.com%2Fuploads%2Farticles%2F39x43upsykqln99yroez.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.amazonaws.com%2Fuploads%2Farticles%2F39x43upsykqln99yroez.png" alt="Finder 2026-06-14 17.53.52" width="800" height="1739"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;During the process of connecting this dynamic Quick Reply to the Batch API, we encountered several critical UX and API limitation issues:&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall One: LINE 20-character Limit Causing API Sending Errors
&lt;/h3&gt;

&lt;p&gt;Initially, when implementing, we directly used the full restaurant name in the button's Label, for example: &lt;code&gt;🍴 Analyze Love Hot Pot Ultimate Hot Pot&lt;/code&gt;. As a result, the LINE API immediately returned a 400 error, and the message could not be sent at all:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
plaintext
LineBotApiError: status_code=400, error_message=The property 'label' must be less than 20 characters.



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

&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;[Cause Analysis and Solution]&lt;/strong&gt; LINE's official &lt;code&gt;label&lt;/code&gt; limit for Quick Reply is extremely strict; &lt;strong&gt;including emojis and spaces, it can have a maximum of 20 characters&lt;/strong&gt;. To address this, we added a character count check and dynamic truncation mechanism in our code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First, the original restaurant name (&lt;code&gt;clean_label&lt;/code&gt;) is truncated: if its length exceeds 10 characters, it is forcibly cut to the first 9 characters and appended with "…" (occupying 10 characters).&lt;/li&gt;
&lt;li&gt;Adding the prefix &lt;code&gt;🍴 Analyze&lt;/code&gt; (a total of 5 characters), the maximum total length becomes 15 characters, safely staying within the 20-character limit, thus eliminating the error!&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pitfall Two: Batch API Asynchronous Delay and LINE Webhook's "Three-Second Timeout Survival Battle"
&lt;/h3&gt;

&lt;p&gt;When a user clicks the "Analyze Restaurant" button, the Bot must first call Google Search Grounding to collect online reviews for that restaurant, then package the JSONL file and upload it to Gemini to submit the Batch task. This entire sequence usually takes 3 to 8 seconds. However, &lt;strong&gt;the LINE Webhook server requires the Bot to return an HTTP 200 OK response within 3 seconds&lt;/strong&gt;, otherwise it will be deemed a connection failure and re-send the request, leading to severe server congestion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[Cause Analysis and Solution]&lt;/strong&gt; We completely asynchronous the processing architecture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fast Response&lt;/strong&gt;: When the Bot intercepts a &lt;code&gt;specific_foodie_deep_analysis&lt;/code&gt; Postback action, &lt;strong&gt;it does not execute the analysis directly within the Request flow&lt;/strong&gt;. Instead, it immediately calls LINE's &lt;code&gt;reply_message&lt;/code&gt; to respond to the user: &lt;code&gt;

🔍 Received! Performing deep analysis for you... This will take about 1-2 minutes...&lt;/code&gt;, and then instantly returns HTTP 200 to end that Webhook request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background Task Dispatch&lt;/strong&gt;: Use Python &lt;code&gt;asyncio.create_task&lt;/code&gt; to dispatch heavy network search, upload, and submission tasks to FastAPI's background Worker for execution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Big Data Push&lt;/strong&gt;: When the background Polling listener or Gemini Webhook receives a task completion notification, it then uses LINE's &lt;code&gt;push_message&lt;/code&gt; to proactively send the analysis report to the specific user.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Pitfall Three: Gemini Batch API's Queuing and Pending Status
&lt;/h3&gt;

&lt;p&gt;During testing, users sometimes got confused, "Why hasn't there been a reply after three minutes? Is the Bot down?". After checking the system logs, we found that our JSONL file had been successfully uploaded, but the task status on the Gemini server side was stuck at &lt;code&gt;JobState.JOB_STATE_PENDING&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[Solution]&lt;/strong&gt; This is a characteristic of the Batch API; tasks need to be queued, waiting for Google's server resources. We adopted two major optimizations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Minimize Workload&lt;/strong&gt;: Reduce the number of restaurants for batch analysis to 1, shrinking the number of request lines in the JSONL to the extreme, to speed up Gemini's scheduling and processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UX Optimization and Deduplication Mechanism&lt;/strong&gt;: When a user clicks to analyze, we first check if that user already has a Batch Job running. If so, we reply: &lt;code&gt;⏳ Your deep analysis task is currently running, please wait patiently&lt;/code&gt;, preventing users from submitting multiple duplicate Batch Jobs due to anxious repeated clicks, which would consume unnecessary resources.&lt;/li&gt;
&lt;/ol&gt;




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

&lt;p&gt;This optimization of Quick Reply and Gemini Batch API for the &lt;strong&gt;LINE Bot Restaurant Assistant&lt;/strong&gt; has achieved excellent practical value:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Highly Customized Mobile Experience&lt;/strong&gt;: After locating, users don't need to type; they can directly click on a restaurant of interest with one tap to precisely get a summary of its signature dishes and review pain points.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Robust Backend Architecture&lt;/strong&gt;: By leveraging asynchronous background tasks and LINE's character limit safety valve, the risks of Webhook timeouts and LINE API errors have been completely resolved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost Advantage for Big Data Processing&lt;/strong&gt;: Through the Batch API's half-price advantage and Webhook's proactive callback, while ensuring user experience, it also saves significant computing resources and API costs for the server.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Through this architecture, the LINE Bot truly achieves a low-latency, highly stable big data deep analysis experience on mobile!&lt;/p&gt;

&lt;p&gt;All development code for this project has been open-sourced on GitHub: &lt;a href="https://github.com/kkdai/linebot-helper-python" rel="noopener noreferrer"&gt;kkdai/linebot-helper-python&lt;/a&gt;. Everyone is welcome to deploy and personally test this one-click analysis function, which we believe can bring a higher level of intelligent experience to your LINE Bot projects!&lt;/p&gt;

</description>
      <category>api</category>
      <category>gemini</category>
      <category>llm</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
