<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Guo Li · leeguoo</title>
    <description>Field notes by Guo Li (leeguoo): reverse engineering, AI agents, developer tools and architecture decisions.</description>
    <link>https://blog.leeguoo.com/en/</link>
    <atom:link href="https://blog.leeguoo.com/en/rss.xml" rel="self" type="application/rss+xml" />
    <image><url>https://blog.leeguoo.com/social-card-v4.png</url><title>Guo Li · leeguoo</title><link>https://blog.leeguoo.com/en/</link></image>
    <language>en-us</language>
    <lastBuildDate>Thu, 15 Oct 2026 23:39:57 GMT</lastBuildDate>
  <item>
    <title>chrome-use in five minutes: let any agent use your own Chrome</title>
    <description>A five-minute walkthrough of why agents should use the Chrome you&#39;re already signed into, how chrome-use connects to it, how it leaves your tabs alone, and what happens at a captcha.</description>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-video-walkthrough/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-video-walkthrough/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Mon, 12 Oct 2026 04:19:55 GMT</pubDate>
  </item>
  <item>
    <title>motion-use: your coding agent writes the video as code</title>
    <description>motion-use is an open-source video tool. Your coding agent writes exact-time drawing code, renders it locally, then decodes the finished MP4 and checks it. With a one-minute film where every clip shown was made with it.</description>
    <content:encoded><![CDATA[<article class="article-content">
<figure class="video-embed" data-provider="hosted" style="margin:24px 0"><div class="video-embed__frame" style="position:relative;width:100%;aspect-ratio:16/9;background:#000;border-radius:8px;overflow:hidden"><video src="https://img.leeguoo.com/media/429d8360-f719-4507-8462-27c887b12792/motion-use-en.mp4" controls playsinline preload="metadata" poster="https://img.leeguoo.com/media/74cd9996-a0d2-463d-afb6-ecd794ffb5e6/motion-use-en-cover.png" title="motion-use: your coding agent writes the video as code" style="position:absolute;inset:0;width:100%;height:100%;border:0;object-fit:contain"></video></div></figure>
<p>motion-use lets a coding agent such as Claude Code or Codex make your videos. The agent writes exact-time drawing code, renders it on your machine, then decodes the finished MP4 and checks it.</p>
<p>The one-minute film above was made that way. Every film shown in it was made with motion-use, and the terminal shows real command output.</p>
<h2>The agent edits code, not a timeline</h2>
<p>Each frame is a function of time. The 55-second iphone-use example in the film is 26 kilobytes of source, so changing a line of narration or moving a shot is a few lines of code and few tokens.</p>
<p>Each film gets a look of its own: felt 3D, whiteboard, kinetic type, charts.</p>
<h2>It checks its own output</h2>
<p>After <code>motion-use render</code>, the MP4 is decoded and measured:</p>
<ul>
<li>whether enough moves, and whether there are long blank stretches;</li>
<li>loudness;</li>
<li>for vertical video, what a platform's buttons cover, so text stays clear of it;</li>
<li>narration is transcribed and compared line by line with the script;</li>
<li>text that runs off the frame, and two texts overlapping, are reported with times.</li>
</ul>
<p>The last three came from mistakes in the iphone-use promo: the voice misread a word, a label ran off the right edge, and two captions overlapped during a crossfade.</p>
<h2>One source, four versions</h2>
<p>The same source gives Chinese and English, landscape and vertical. <code>render --release</code> adds high-quality masters for each platform plus a manifest with hashes, so you can check you are posting the right cut.</p>
<p>Install:</p>
<figure class="t-codeblock" data-lang="sh"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">sh</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-sh">curl -fsSL https://raw.githubusercontent.com/leeguooooo/motion-use/main/install.sh | sh
</code></pre></figure>
<p>Code and examples are on <a href="https://github.com/leeguooooo/motion-use" rel="noopener noreferrer nofollow" target="_blank">GitHub: leeguooooo/motion-use</a>. The film's voice is synthetic.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/motion-use/</link>
    <guid>https://blog.leeguoo.com/en/posts/motion-use/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Sun, 11 Oct 2026 15:48:26 GMT</pubDate>
  </item>
  <item>
    <title>switch-use: Let AI agents operate a real Nintendo Switch</title>
    <description>An open-source command-line tool that lets agents like Claude Code and Codex operate a Switch running Atmosphère and sys-botbase over a local network: OCR for reading screen text, tapping by text, waiting for screens to appear, buttons and sticks, touch, and screenshots.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>I had Claude Code help me figure out why Animal Crossing: New Horizons wouldn’t open. It went back to HOME on its own, went through “All Software,” entered “Detailed Management” for the software in System Settings, and finally told me: the console only had 508 MB of update data left, and the base game was gone, so the system was asking me to insert the game card.</p>
<p>I didn’t touch the controller the entire time. The thing that did it was <a href="https://github.com/leeguooooo/switch-use" rel="noopener noreferrer nofollow" target="_blank">switch-use</a>, an open-source Rust command-line tool of about 1 MB.</p>
<p><img src="https://img.leeguoo.com/media/c90e2594-9d60-49ea-be3b-74b8e99af4a7/switch-acnh-data.jpg?v=3" alt="Animal Crossing data management page: only two sets of update data remain, 66.6 MB in system memory and 441 MB on the SD card, totaling 508 MB" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<h2>How it connects to the Switch</h2>
<p>The console needs to run Atmosphère and have the <a href="https://github.com/olliz0r/sys-botbase" rel="noopener noreferrer nofollow" target="_blank">sys-botbase</a> system module installed. sys-botbase opens TCP port 6000 on the console and accepts line-by-line text commands: buttons, sticks, touch, and screenshots. switch-use is a client for that protocol. As long as the computer and console are on the same Wi-Fi network, that’s enough—no USB cable and no resident process required.</p>
<figure class="t-codeblock" data-lang="sh"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">sh</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-sh">curl -fsSL https://raw.githubusercontent.com/leeguooooo/switch-use/main/install.sh | sh
switch-use discover    # Scan the local network, find the console, and remember its address
switch-use status      # Battery, sys-botbase version, currently running game
</code></pre></figure>
<h2>The Switch has no accessibility tree, so OCR fills the gap</h2>
<p>I previously made <a href="https://github.com/leeguooooo/iphone-use" rel="noopener noreferrer nofollow" target="_blank">iphone-use</a>. iPhone has an accessibility tree, so the text and coordinates of every button can be read directly, and an agent can operate it by reading text. The Switch system does not expose such a layer. All sys-botbase can provide is screenshots.</p>
<p>So switch-use uses Apple’s Vision framework on Mac to recognize screenshots, listing each line of text and its center point. The coordinates are touch coordinates, so they can be tapped directly:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">$ switch-use elements
[1] Animal Crossing: New Horizons  (425,64)
[4] Software Information  (240,192)
[9] Data Management  (240,351)
[18] OK  (1085,685)
</code></pre></figure>
<p>On top of that, there are three commands, all modeled after iphone-use’s usage:</p>
<ul>
<li><code>tap Data Management</code>: find that text and tap it. If there are multiple places with the same name on screen, it lists them for you to choose from; if it can’t find it, it shows the closest matches.</li>
<li><code>wait Continue</code> and <code>press A see:Continue</code>: wait for a piece of text to appear before moving on, without hard-coding <code>sleep 1.5</code>.</li>
<li><code>--observe</code>: add it after any operation. After the operation finishes, it waits for the screen to stabilize and returns the text from the new screen directly. The agent needs one fewer separate screen-reading step.</li>
</ul>
<p>It can recognize Chinese, Japanese, and English. Pixel fonts and cover art inside games are recognized poorly; those results are marked with a question mark.</p>
<h2>Pitfalls found on real hardware</h2>
<p><strong>The HOME button is a toggle.</strong> If the console is already on HOME and you press it again, it switches back to the running game. My first version of <code>home</code> just pressed it directly, and during testing it brought Contra back up. Now <code>home</code> first uses OCR to check whether there is a clock in the top-right corner, which only appears in the HOME menu. If it is already on HOME, it does nothing.</p>
<p><strong>Button presses can be swallowed during screen transitions.</strong> I used <code>DRIGHT*12</code> to try to move the cursor to “All Software,” but several right presses were lost, and A landed on Battle City. Another game was still open, so the system popped up “The currently running software will be closed,” with focus conveniently sitting on “Close.”</p>
<p><img src="https://img.leeguoo.com/media/f55b06f0-7dd9-4779-b7ff-1da3d0fa75a1/switch-close-dialog.jpg?v=3" alt="Close software dialog that appeared after a mistaken press, with focus on “Close”" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>I pressed B to cancel, and nothing happened. After that, the skill gained another rule: before pressing A on HOME, first read the name of the item under the cursor—the name is only shown above the selected item—and press only if it matches.</p>
<p><strong>A loading spinner is not text.</strong> The first time entering “All Software,” the page was spinning. Two OCR passes before and after both only read the title bar, so <code>--observe</code> thought the screen was stable and returned early. Later I added pixel comparison: shrink the screenshot into a 160×90 grayscale grid and count how many cells differ by more than 60 between consecutive frames. Measured on the console, the breathing highlight effect of the selected HOME item changes at most 5 cells, while a loading page changes more than 1700 cells in one frame, so the threshold is set at 8 cells. Now the screen only counts as stable once both text and pixels stop moving; for screens that keep moving, such as animations or very long loads, it returns after 4 seconds and notes in the output that “the screen is still changing.”</p>
<p><strong>The first touch on HOME only selects.</strong> For a while I thought touch didn’t work in docked mode, because tapping the Settings icon didn’t open it. In reality, the first tap only moved the cursor onto the icon; a second tap opened it. sys-botbase’s virtual touch works in docked mode too. Now <code>touch</code> and <code>tap</code> compare the screen before and after tapping; if nothing changes, they report an error, so the agent won’t think the tap succeeded.</p>
<h2>For agents</h2>
<p>The repository includes a <a href="https://github.com/leeguooooo/switch-use/blob/main/SKILL.md" rel="noopener noreferrer nofollow" target="_blank">SKILL.md</a> with operating procedures and the pitfalls above. Claude Code can install the plugin:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">/plugin marketplace add leeguooooo/plugins
/plugin install switch-use@leeguooooo-plugins
</code></pre></figure>
<p>It is also part of the <a href="https://github.com/leeguooooo/plugins" rel="noopener noreferrer nofollow" target="_blank">use family</a>; installing <code>use-family</code> brings it along. The skill hard-codes several rules: ask before interrupting a running game; don’t change system settings, delete data, or buy anything unless the user explicitly requests it; don’t let the console connect to Nintendo services. Ideally, the console should be in a virtual system, emuMMC, and use Atmosphère’s hosts to block Nintendo servers.</p>
<h2>Want to play with it yourself: Pocket Gamepad</h2>
<p>The sys-botbase port is not only useful for agents. I also made a Mac app called <a href="https://app.leeguoo.com/pocket-gamepad" rel="noopener" target="_blank">Pocket Gamepad</a>, which follows the same path: connect to the console’s sys-botbase and turn the Mac keyboard or an idle phone into a Switch controller. The phone doesn’t need anything installed; scan the QR code on the Mac and play in landscape.</p>
<p>When friends come over and there is only one pair of Joy-Con, the third person uses it. It has a 7-day free trial, then a one-time purchase of US$1.99. Setup and button mapping are described in <a href="https://blog.leeguoo.com/en/posts/switch-extra-controller-keyboard-phone-atmosphere/" rel="noopener" target="_blank">Not enough Switch controllers? Use a Mac keyboard and phone to add another controller</a>.</p>
<p>The two tools can run together: sys-botbase accepts multiple connections at the same time, so you can play with Pocket Gamepad while the agent can still take screenshots and read the screen.</p>
<h2>What it still cannot do</h2>
<ul>
<li>OCR is only available on macOS. On Linux, buttons, touch, and screenshots all work, but you need to read the screen yourself from the image.</li>
<li>OCR cannot tell where the cursor is. Most Switch menus are navigated with the directional buttons, and the selected item is indicated by a highlighted frame, not text. After moving the cursor, you still need to check the screenshot to confirm.</li>
<li>Screenshots are slow. sys-botbase sends JPEGs as hexadecimal. A HOME screen takes about 0.45 seconds, while an “All Software” screen full of cover art takes around 1.4 seconds.</li>
</ul>
<p>Code and installation instructions are on <a href="https://github.com/leeguooooo/switch-use" rel="noopener noreferrer nofollow" target="_blank">GitHub</a>, dual-licensed under MIT or Apache-2.0.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/switch-use/</link>
    <guid>https://blog.leeguoo.com/en/posts/switch-use/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Sun, 11 Oct 2026 15:39:04 GMT</pubDate>
  </item>
  <item>
    <title>Not Enough Switch Controllers? Atmosphère Users Can Turn a Mac Keyboard or Phone into an Extra Controller</title>
    <description>Friends came over but you only have two Joy-Con? If your Switch has Atmosphère and sys-botbase installed, Pocket Gamepad can turn your Mac keyboard or spare phone into a third controller, with a 7-day free trial and a one-time US$1.99 purchase afterward—no need to buy a Pro Controller.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>Friends come over on the weekend and want to play <em>Mario Party</em> or a four-player round of <em>Overcooked</em>. You count your controllers: one pair of Joy-Con. Split apart, that is just enough for two people. The third person can only watch from the side.</p>
<p>Buying another Pro Controller costs three or four hundred yuan, and you might only use it a few times a year. If your Switch has Atmosphère installed, there is a much cheaper option: <strong>use Pocket Gamepad to turn your Mac keyboard, or an old phone sitting in a drawer, into a third Switch controller.</strong></p>
<blockquote>
<p>To be clear about compatibility: Pocket Gamepad only supports a <strong>Switch with Atmosphère and sys-botbase installed</strong>. It cannot be used with a stock-system Switch. The reason is explained in the FAQ at the end.</p>
</blockquote>
<h2>What Pocket Gamepad Can Do</h2>
<p>Pocket Gamepad is a Mac app. After installing it:</p>
<ul>
<li><strong>Use the keyboard as a controller</strong>: WASD is the left stick, K/J/I/U are A/B/X/Y, and Q/E are L/R. It works in any app, and you can toggle it with ⌃`</li>
<li><strong>Use your phone as a controller</strong>: connect the phone and Mac to the same Wi-Fi, scan the QR code in the Pocket Gamepad window, and a landscape controller appears in the browser, with two sticks and all buttons. No app needs to be installed on the phone</li>
<li><strong>Keyboard and phone input can be used at the same time</strong>: both control the same controller, and input from either one counts</li>
</ul>
<p>It does not require extra hardware or a data cable. The Mac sends button input over Wi-Fi to sys-botbase on the Switch, and the Switch sees it as a newly connected Pro Controller.</p>
<p><strong>How many extra controllers you get</strong>: one. sys-botbase only provides one virtual controller, so two Joy-Con plus Pocket Gamepad means three people can play in total. The keyboard and phone control the same controller; they cannot count as separate players.</p>
<h2>Preparation</h2>
<p>You need three things:</p>
<ol>
<li>A Switch with <strong>Atmosphère</strong> installed, and <strong>sys-botbase</strong> already installed</li>
<li>A Mac running macOS 14 or later</li>
<li>The Switch and Mac connected to the <strong>same Wi-Fi</strong></li>
</ol>
<h3>Install sys-botbase</h3>
<p>Skip this step if you have already installed it.</p>
<ol>
<li>Download the latest version from GitHub: <a href="https://github.com/olliz0r/sys-botbase/releases" rel="noopener noreferrer nofollow" target="_blank">olliz0r/sys-botbase</a></li>
<li>After extracting it, copy the <code>atmosphere</code> folder to the root of the SD card and merge it with the existing one</li>
<li>Restart the Switch and boot into Atmosphère</li>
</ol>
<p>sys-botbase listens on port 6000 on the Switch. Pocket Gamepad connects to this port.</p>
<h2>Start Playing in Three Steps</h2>
<p><strong>Step 1: Download Pocket Gamepad</strong></p>
<p><a href="https://app.leeguoo.com/pocket-gamepad/download" rel="noopener" target="_blank">Download Pocket Gamepad for Mac</a> (7-day free trial, then a one-time US$1.99 purchase; notarized by Apple). The product page and release notes are at <a href="https://app.leeguoo.com/pocket-gamepad" rel="noopener" target="_blank">app.leeguoo.com/pocket-gamepad</a>.</p>
<p>The first time you open it, macOS will ask for two permissions:</p>
<ul>
<li><strong>Local Network</strong>: choose “Allow”, otherwise it cannot connect to the Switch</li>
<li><strong>Accessibility</strong>: click “Enable Permission” in Pocket Gamepad. After enabling it, the keyboard can control the Switch even when you switch to another window. Without it, Pocket Gamepad still works, but its window must stay in the foreground</li>
</ul>
<p><strong>Step 2: Turn on “One-Click Connect”</strong></p>
<p>There is a “One-Click Connect” toggle at the top of the Pocket Gamepad window. After you turn it on, it automatically finds the Switch on the local network, connects to it, and enables keyboard takeover. Once the top shows “Connected”, you are ready.</p>
<p><strong>Step 3: Assign the Controller on the Switch</strong></p>
<p>On the Switch, go to “Controllers → Change Grip/Order”. On the keyboard, <strong>press Q and E at the same time</strong> (equivalent to L + R). A new Pro Controller will appear on the screen. Then press K (equivalent to A) to confirm.</p>
<p>For friends using a phone, open the URL shown in the Pocket Gamepad window or scan the QR code, rotate the phone to landscape, and start playing.</p>
<h2>Default Controls</h2>





















































<table><thead><tr><th>Keyboard</th><th>Switch Controller</th></tr></thead><tbody><tr><td>W A S D</td><td>Left stick (hold Shift for half tilt / slow walk)</td></tr><tr><td>Arrow keys</td><td>D-pad</td></tr><tr><td>K or Space</td><td>A</td></tr><tr><td>J</td><td>B</td></tr><tr><td>I</td><td>X</td></tr><tr><td>U</td><td>Y</td></tr><tr><td>Q / E</td><td>L / R</td></tr><tr><td>Z / C</td><td>ZL / ZR</td></tr><tr><td>Enter / Backspace</td><td>+ / −</td></tr><tr><td>H</td><td>HOME</td></tr><tr><td>P</td><td>Screenshot</td></tr></tbody></table>
<p>All controls can be changed on the “Controls” page in Pocket Gamepad. If you need the right stick to move the camera in 3D games, choose the “3D Action” preset, and the arrow keys will become the right stick.</p>
<h2>Which Games Are a Good Fit</h2>
<p>It is suitable for party and turn-based games: <em>Mario Party</em>, <em>Overcooked</em>, multiplayer mode in <em>Super Mario Bros. Wonder</em>, <em>Pokémon</em>, and various board game collections. These types of games are not too demanding on controller feel, so a keyboard or phone screen is enough.</p>
<p>Input is sent over Wi-Fi. For very fast-paced fighting games and rhythm games, it is recommended to test the latency yourself first and see whether it feels acceptable.</p>
<h2>FAQ</h2>
<p><strong>Can it be used with a stock Switch?</strong></p>
<p>No. The stock system only accepts real controllers connected by Bluetooth or USB, and neither an iPhone nor a Mac can impersonate a Bluetooth controller. Pocket Gamepad relies on sys-botbase on Atmosphère, so it only supports Switch consoles with Atmosphère installed.</p>
<p><strong>How many extra controllers can it add?</strong></p>
<p>One. With two Joy-Con plus Pocket Gamepad, at most three people can play at the same time.</p>
<p><strong>It shows connected, but button input does not work.</strong></p>
<p>Most likely, after the Switch woke from sleep, the virtual controller got stuck. Click “Reset Virtual Controller” in Pocket Gamepad, then go back to “Change Grip/Order” and press Q + E again.</p>
<p><strong>Does it cost money?</strong></p>
<p>After downloading, you get a 7-day free trial. After that, it is a one-time US$1.99 purchase for permanent use, with no subscription. One license can activate 3 Macs.</p>
<p><strong>Does the phone need an app?</strong></p>
<p>Not right now. Opening it in the browser is enough, but the Mac must keep Pocket Gamepad running. A native iPhone app is in progress. Once it is ready, the phone will be able to connect directly to the Switch without needing the Mac.</p>
<p><strong>Does it work on Windows?</strong></p>
<p>For now, there is only a Mac version.</p>
<p><strong>What if it cannot connect to the Switch?</strong></p>
<p>Check the following in order: whether the Switch is awake; whether it has booted into Atmosphère (not the stock system); whether sys-botbase is installed correctly; whether the Switch and Mac are connected to the same Wi-Fi; and whether Pocket Gamepad is allowed under macOS “System Settings › Privacy &#x26; Security › Local Network”.</p>
<p><strong>Can I let AI operate my Switch for me?</strong></p>
<p>Yes. I also made an open-source command-line tool called <a href="https://github.com/leeguooooo/switch-use" rel="noopener noreferrer nofollow" target="_blank">switch-use</a>. It also connects to sys-botbase, letting agents such as Claude Code and Codex read screen text, press buttons, touch the screen, and take screenshots—for example, to help you figure out why a game will not open. The development process and real-hardware pitfalls are covered in <a href="https://blog.leeguoo.com/en/posts/switch-use/" rel="noopener" target="_blank">switch-use: Let AI agents operate a real Nintendo Switch</a>. The two can run at the same time, but agent button input also uses this same virtual controller, so do not let it press buttons while you are playing.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/switch-extra-controller-keyboard-phone-atmosphere/</link>
    <guid>https://blog.leeguoo.com/en/posts/switch-extra-controller-keyboard-phone-atmosphere/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Sun, 11 Oct 2026 15:20:08 GMT</pubDate>
  </item>
  <item>
    <title>iphone-use in one minute: let AI use your iPhone</title>
    <description>A one-minute iphone-use video where every phone shot is a real recording. The AI reads the screen, taps by name, says when a step didn&#39;t land, and replays finished tasks without spending tokens.</description>
    <content:encoded><![CDATA[<article class="article-content">
<figure class="video-embed" data-provider="hosted" style="margin:24px 0"><div class="video-embed__frame" style="position:relative;width:100%;aspect-ratio:16/9;background:#000;border-radius:8px;overflow:hidden"><video src="https://img.leeguoo.com/media/2d4097e0-5447-4c3e-b95f-1d06dcace564/iphone-use-en.mp4" controls playsinline preload="metadata" poster="https://img.leeguoo.com/media/d0d2a482-396d-4ac5-ae3f-4b9b27d0ab07/iphone-use-en-cover.png" title="iphone-use: let AI use your iPhone" style="position:absolute;inset:0;width:100%;height:100%;border:0;object-fit:contain"></video></div></figure>
<p>iphone-use is an open-source tool that lets Claude Code, Codex and other MCP clients use your iPhone the way you do. Apps don't need an API: it reads the screen and taps buttons.</p>
<p>Every phone shot in the video is a real recording, and the terminal shows the tool's real output.</p>
<p>It reads the screen as text in about a tenth of a second, then taps buttons by name. When a step doesn't land it says so, and tells you whether a retry is safe. A task that worked can be saved as a flow and replayed later without calling the model, so it costs no tokens. Plug in a cable once and it works over Wi-Fi after that, back to iOS 15. There is also a browser remote control, and the iPhone app can pair to control other phones.</p>
<p>Install with one line:</p>
<figure class="t-codeblock" data-lang="sh"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">sh</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-sh">curl -fsSL https://raw.githubusercontent.com/leeguooooo/iphone-use/main/install.sh | sh
</code></pre></figure>
<p>The code is on <a href="https://github.com/leeguooooo/iphone-use" rel="noopener noreferrer nofollow" target="_blank">GitHub: leeguooooo/iphone-use</a>; the longer write-up is the <a href="https://blog.leeguoo.com/en/posts/iphone-use/" rel="noopener" target="_blank">iphone-use project page</a>.</p>
<p>The video was made with the open-source <a href="https://github.com/leeguooooo/motion-use" rel="noopener noreferrer nofollow" target="_blank">motion-use</a>; the voice is synthetic.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/iphone-use-video/</link>
    <guid>https://blog.leeguoo.com/en/posts/iphone-use-video/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Sat, 10 Oct 2026 14:53:19 GMT</pubDate>
  </item>
  <item>
    <title>Having Claude Opus 5.5 Code a 3D Promo Video: 1,250 Lines of Code, Not a Single Manually Animated Frame</title>
    <description>With a single request, Opus 5.5 wrote the director’s notes, three.js scenes, a music synthesizer, and subtitles in Claude Code; reviewed extracted frames itself to fix bugs; then used the open-source motion-use to render a 102-second, 6,120-frame finished video locally.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>First, watch the finished video: 102 seconds, 60 fps, with Chinese voiceover and subtitles.</p>
<figure class="video-embed" data-provider="hosted" style="margin:24px 0"><div class="video-embed__frame" style="position:relative;width:100%;aspect-ratio:16/9;background:#000;border-radius:8px;overflow:hidden"><video src="https://img.leeguoo.com/media/2a4bad30-a008-48db-98a9-5beb68e3bfa3/motion-use-promo.mp4" controls playsinline preload="metadata" title="Video" style="position:absolute;inset:0;width:100%;height:100%;border:0;object-fit:contain"></video></div></figure>
<p>Every object on screen, every camera movement, and every note was computed by code. No AE, no keyframe animation, no asset library, and no text-to-video model. The thing that did it was Claude Opus 5.5 running in Claude Code, with rendering handled by our own open-source <a href="https://github.com/leeguooooo/motion-use" rel="noopener noreferrer nofollow" target="_blank">motion-use</a>.</p>
<h2>What I Gave It</h2>
<p>One request: use three.js to make a one- to two-minute promo video for motion-use, with voiceover, explaining how it works and what it can do. During the process, I only gave feedback once: the first frame was black, and the README player would use it as the cover, which would look bad. It changed frame 0 into a complete opening shot.</p>
<h2>What It Did</h2>
<p><strong>Verify first, then write the script.</strong> For every claim about motion-use in the voiceover, it first looked through the repository source code for supporting evidence: how the renderer seeks frame by frame, how fonts are subsetted, and what the acceptance checks test. The sources are recorded in the <a href="https://github.com/leeguooooo/motion-use/blob/main/examples/motion-use-promo/DIRECTOR.md" rel="noopener noreferrer nofollow" target="_blank">director’s notes</a>.</p>
<p><strong>The voiceover determined the timeline.</strong> It first wrote 9 voiceover segments, generated speech, measured the actual duration of each segment, and then set the shot windows based on those durations. motion-use has strict rules: if the voiceover exceeds the shot, rendering fails directly. It does not truncate it for you.</p>
<p><strong>One line tells the whole story.</strong> The opening request falls to the ground and melts into a glowing timeline. Then storyboard cards stand up along it, a playhead scrubs back and forth across it, frames rise from it and are captured in parallel, voiceover blocks are fitted into its shot windows, and the finished video passes through acceptance scanning rings along it. The little felt monster from the previous work serves as the guide.</p>
<p><strong>The 3D scene is about 1,000 lines of three.js</strong>: a mirror floor with a black lacquer layer, felt material using sheen plus a translucent fuzzy shell layer, Bloom for glow, and every pose as a pure function of time, so any frame at any moment can be computed independently.</p>
<p><strong>The music and sound effects are Python-synthesized waveforms</strong>: a 96 BPM base, with typing, shutter, error, and pass notification sounds aligned to the timing of on-screen events. Melodies only appear in gaps between voiceover segments.</p>
<h2>Problems It Caught Itself</h2>
<p>This was the most interesting part. It did not just hand things over after writing them; it repeatedly extracted frames, inspected images, and made fixes:</p>
<ul>
<li>In the first version, almost every segment was framed too far away, the text was too small, and the storyboard cards were blown out into white blocks by Bloom.</li>
<li>Frames on the right half of the filmstrip disappeared, but they were still visible in the floor reflection. It judged that the sorting of the translucent backing panel was covering the frames, and fixed it by locking the draw order.</li>
<li>The camera paused at every keyframe, causing the opening shot to sit still for 0.8 seconds. It changed the interpolation to be continuous.</li>
<li>In the previous video, around 30 frames in the draft had black squares. It traced the issue to individual pixels producing NaN values, which Bloom then blurred and spread into an entire block, so it added a bitwise detection cleanup step at the very start of the post-processing chain. That fix was also reused in this video.</li>
</ul>
<p>Before delivery, motion-use decodes the entire finished video, measures the actual duration, frame rate, and loudness, and checks whether the voiceover was actually mixed in by comparing waveform correlations per shot. This video: 0 faulty frames, all 9 voiceover segments passed detection, loudness -14.5 LUFS.</p>
<h2>What It Did Not Do</h2>
<ul>
<li>The voiceover is AI speech, generated with edge-tts.</li>
<li>It cannot hear audio. For the music, it only measured spectrum and loudness; the listening experience still needs to be judged by a human.</li>
<li>For now, there is only a Chinese version.</li>
</ul>
<h2>Try It Yourself</h2>
<p>motion-use is open source. After installing it, have your coding agent read its skill, and it can use the same workflow to make videos:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/leeguooooo/motion-use/main/install.sh | sh
motion-use doctor
</code></pre></figure>
<p>The full source code for this video is in <a href="https://github.com/leeguooooo/motion-use/tree/main/examples/motion-use-promo" rel="noopener noreferrer nofollow" target="_blank">examples/motion-use-promo</a>, and the Bilibili version is <a href="https://www.bilibili.com/video/BV1yhpe6bEwt" rel="noopener noreferrer nofollow" target="_blank">here</a>.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/claude-opus-5-5-threejs-promo-film/</link>
    <guid>https://blog.leeguoo.com/en/posts/claude-opus-5-5-threejs-promo-film/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Sat, 10 Oct 2026 02:17:52 GMT</pubDate>
  </item>
  <item>
    <title>Making Claude Code and Codex talk to each other — even across two computers</title>
    <description>I run Claude Code, Codex and Pi side by side, and they can&#39;t see each other. ocs gives every AI coding session a shared channel and actually wakes the target session — on one machine or across the computers on your LAN. How it works, and the &quot;it said OK but the message was gone&quot; bugs I had to design around.</description>
    <link>https://blog.leeguoo.com/en/posts/ocs-cross-agent-wake/</link>
    <guid>https://blog.leeguoo.com/en/posts/ocs-cross-agent-wake/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 30 Sep 2026 05:32:28 GMT</pubDate>
  </item>
  <item>
    <title>Pastyx: everything you&#39;ve copied on your Mac, one keystroke away</title>
    <description>Copied a snippet, then a link, and lost the snippet? Pastyx remembers everything you copy. Press ⌘⇧V, pick, press Return. A native, local-first clipboard manager for macOS 26 — US$1.99 once, 7-day free trial.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>You copy a snippet, then a link, and the snippet is gone. Your Mac's clipboard only remembers the last thing.</p>
<p><strong>Pastyx remembers everything you copy.</strong> Press ⌘⇧V and your recent text, links, code and images line up; pick one and press Return to paste it into the app you're in.</p>
<p><a href="https://paste.leeguoo.com/download?utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=pastyx" rel="noopener" target="_blank">Try it free for 7 days</a> · <a href="https://paste.leeguoo.com/buy?utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=pastyx" rel="noopener" target="_blank">Buy — US$1.99</a> · <a href="https://app.leeguoo.com/paste?lang=en&#x26;utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=pastyx" rel="noopener" target="_blank">Product page</a></p>
<h2>How it feels</h2>
<ul>
<li><strong>⌘⇧V, then just type</strong>: find something you copied weeks ago with a few letters.</li>
<li><strong>Return pastes</strong>, ⇧Return pastes as plain text with no stray formatting.</li>
<li><strong>⌘1–⌘9</strong> pastes one of your last nine clips.</li>
<li><strong>Knows what you copied</strong>: links with their site icon, code detected automatically, colour values as swatches, and the app each clip came from.</li>
<li><strong>Filter by type</strong>: Favorites, Text, Links, Code, Images — Tab to switch.</li>
<li><strong>Favorites never expire</strong>: keep your signature, addresses and go-to commands one keystroke away.</li>
<li><strong>Drag a clip</strong> straight into another app.</li>
</ul>
<h2>Safe by default</h2>
<ul>
<li><strong>Passwords stay out</strong>: items password managers mark as concealed are skipped; Keychain, 1Password and Bitwarden are ignored; ignore any app you like, or pause capture from the menu bar.</li>
<li><strong>Local-first</strong>: history lives on your Mac, kept for 30 days or forever, your choice. No account needed.</li>
<li><strong>Sync only if you want</strong>: sign in to sync with the web app at <a href="https://paste.leeguoo.com/?utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=pastyx" rel="noopener" target="_blank">paste.leeguoo.com</a> and your other devices, or keep everything local.</li>
<li><strong>Native and quick</strong>: a menu-bar app whose panel takes the keyboard without stealing focus or switching Spaces, so the paste lands where you were.</li>
</ul>
<p>Requires macOS 26 or newer (Apple silicon or Intel).</p>
<h2>Price</h2>
<p><strong>US$1.99, one-time. No subscription.</strong></p>
<p><a href="https://paste.leeguoo.com/download?utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=pastyx" rel="noopener" target="_blank">Try it free for 7 days</a>, then <a href="https://paste.leeguoo.com/buy?utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=pastyx" rel="noopener" target="_blank">buy</a> if you like it. One licence activates up to 3 Macs.</p>
<p>Or install from the terminal:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/leeguooooo/pastyx/main/install.sh | sh
</code></pre></figure>
<hr>
<p>By <a href="https://leeguoo.com/about/en" rel="noopener" target="_blank">Guo Li (郭立, leeguoo)</a>. Pastyx is an independent product, not affiliated with Paste (pasteapp).</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/pastyx-mac-clipboard-history/</link>
    <guid>https://blog.leeguoo.com/en/posts/pastyx-mac-clipboard-history/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 30 Sep 2026 02:09:35 GMT</pubDate>
  </item>
  <item>
    <title>ChooseBrowser: every link on your Mac opens in the right browser and Chrome profile</title>
    <description>Juggling work, personal and test accounts? ChooseBrowser sends each link to the right browser and Chrome profile by your URL rules. US$1.99 once, 7-day free trial.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>You click a link in Slack or Mail and it opens in the wrong browser, signed in to the wrong Chrome account. Copy the URL, switch windows, paste, again.</p>
<p><strong>ChooseBrowser makes that stop.</strong> Tell it where each site belongs, and every link lands in the right place from then on.</p>
<p><a href="https://choosebrowser.leeguoo.com/choose-browser/?lang=en&#x26;utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=choosebrowser" rel="noopener" target="_blank">Try it free for 7 days</a> · <a href="https://apps.apple.com/app/choosebrowser/id6808098194" rel="noopener noreferrer nofollow" target="_blank">Mac App Store</a> · <a href="https://choosebrowser.leeguoo.com/buy?utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=choosebrowser" rel="noopener" target="_blank">Buy — US$1.99</a></p>
<h2>What it does for you</h2>
<ul>
<li><strong>Work and personal, kept apart</strong>: company GitLab, Jira and docs open in your work profile; personal GitHub and banking in another browser.</li>
<li><strong>Test with many accounts without logging out</strong>: staging in the test account, the admin panel in the admin account, buyer and seller each in their own.</li>
<li><strong>Down to the Chrome profile</strong>: every profile in Chrome, Edge, Brave and Vivaldi is its own target.</li>
<li><strong>Even one site, split two ways</strong>: <code>github.com/your-company</code> in the work profile, the rest in your personal one.</li>
<li><strong>One keystroke for everything else</strong>: links without a rule show a small chooser by the pointer; ⌘1–⌘9 picks, ⌘R saves the choice as a rule.</li>
<li><strong>Learns as you go</strong>: remembers what you pick per site and puts it first.</li>
</ul>
<h2>Stays out of your way</h2>
<ul>
<li>It quits right after opening the link. Nothing running in the background.</li>
<li>No account, no analytics, no tracking. Rules live on your Mac, or sync through your own iCloud.</li>
<li>English, Chinese, Japanese and Korean.</li>
<li>macOS 26 or newer. A free Windows 10/11 preview is available too.</li>
</ul>
<h2>Price</h2>
<p><strong>US$1.99, one-time. No subscription.</strong></p>
<ul>
<li><a href="https://apps.apple.com/app/choosebrowser/id6808098194" rel="noopener noreferrer nofollow" target="_blank">Mac App Store</a>: installs on every Mac with your Apple ID, updates automatically.</li>
<li><a href="https://choosebrowser.leeguoo.com/buy?utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=choosebrowser" rel="noopener" target="_blank">Buy direct</a>: <a href="https://choosebrowser.leeguoo.com/choose-browser/?lang=en&#x26;utm_source=blog.leeguoo.com&#x26;utm_medium=article&#x26;utm_campaign=choosebrowser" rel="noopener" target="_blank">try it free for 7 days</a> first; one licence activates up to 3 Macs.</li>
</ul>
<p>The price of a coffee, for dozens fewer copy-pastes a day.</p>
<hr>
<p>By <a href="https://leeguoo.com/about/en" rel="noopener" target="_blank">Guo Li (郭立, leeguoo)</a></p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/choosebrowser-open-links-in-right-browser/</link>
    <guid>https://blog.leeguoo.com/en/posts/choosebrowser-open-links-in-right-browser/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 30 Sep 2026 02:07:48 GMT</pubDate>
  </item>
  <item>
    <title>ChooseBrowser is on the Mac App Store: rules that decide which browser and which account opens every link</title>
    <description>ChooseBrowser is a link router for macOS. Write rules by domain and path, and each link goes straight to the browser or Chrome profile you picked. Multi-account testing, separate work and personal accounts, juggling clients — set up once with rules. US$1.99, one-time purchase.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>When I test, I'm usually signed in to several accounts at once: an admin, a regular user, a buyer and a seller, each in its own Chrome profile. The trouble is that a link clicked in Slack, an email or a ticket goes to the default browser, and which account it lands in is down to luck. Open it in the wrong one and you're copying the address, switching windows and pasting it again.</p>
<p>ChooseBrowser fixes exactly that. It's a link router: you write rules, and every link goes to the browser and account it belongs in. It's now on the <a href="https://apps.apple.com/app/id6808098194" rel="noopener noreferrer nofollow" target="_blank">Mac App Store</a> for <strong>US$1.99</strong>. One-time purchase, no subscription.</p>
<h2>Rules come first</h2>
<p>Once ChooseBrowser is your default browser (System Settings › Desktop &#x26; Dock › Default web browser), every http/https link on the system passes through it first, and your rules decide where it goes:</p>
<ul>
<li><strong>By domain</strong>: <code>gitlab.company.com</code> always opens in the work account</li>
<li><strong>By wildcard subdomain</strong>: everything under <code>*.staging.example.com</code> goes to a dedicated testing profile</li>
<li><strong>By path</strong>: on the same <code>github.com</code>, <code>/my-org/*</code> uses the work account and everything else the personal one. Path rules win over domain rules</li>
<li><strong>To a specific account</strong>: a rule can point at a browser, or at one particular profile in Chrome, Edge, Brave, Vivaldi or Chromium</li>
<li><strong>Always ask</strong>: some addresses can be set to show the picker every time, so a broader rule doesn't swallow them</li>
</ul>
<p><img src="https://img.leeguoo.com/media/b087490d-fb66-420a-96cc-3692e443beb8/choosebrowser-en-us-appstore-2-rules.png?v=3" alt="Rules can match paths, so one site can go two places" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<p>A link that matches a rule opens straight away, with no window. A link with no rule brings up the picker next to your pointer; press ⌘R there to start a rule from that link, and you won't be asked next time. Every rule can be reviewed and deleted in Settings.</p>
<p>You can also set a default destination so unmatched links skip the picker too. When you want to send one somewhere else, hold Option while you click and the picker comes back.</p>
<h2>Every account is its own target</h2>
<p>Rules can name an account because ChooseBrowser lists each profile of every Chromium-based browser separately, one row per account.</p>
<p><img src="https://img.leeguoo.com/media/a43bd0d3-51fe-4cc5-a36e-f44c2471c73d/choosebrowser-en-us-appstore-3-profiles.png?v=3" alt="Each Chrome profile is its own choice" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>The App Store version runs in a sandbox, so the first time you use this there's a one-off setup to follow on screen: allow ChooseBrowser to read the browser's profile folder, and install a small script it uses to open links in a given profile. After that, there's nothing more to do.</p>
<h2>Where it helps</h2>
<p><strong>Multi-account testing.</strong> Links to the test environment open in the test account, staging in another, the production admin panel in the admin account. When one feature has to be checked as both buyer and seller, write a rule for each path and stop signing in and out.</p>
<p><strong>Keeping work and personal apart.</strong> Company GitLab, Jira and docs go to the work profile; your own GitHub and banking go to another browser, so work and personal cookies never mix.</p>
<p><strong>Several clients or teams.</strong> One profile per client, a rule per client domain or path, and links land in the right client's account on their own.</p>
<p><strong>Picking by hand now and then.</strong> For sites without a rule, the picker appears: Return opens the first entry, ⌘1 to ⌘9 pick by position, ⌘C just copies the link. It also remembers what you chose on each site, moves your usual browser to the top and shows why it's there.</p>
<h2>For AI agents: one set of rules shared with chrome-use</h2>
<p><a href="https://github.com/leeguooooo/chrome-use" rel="noopener noreferrer nofollow" target="_blank">chrome-use</a> is our browser-automation CLI that lets AI agents drive the Chrome you're already signed in to. With ChooseBrowser installed, when an agent runs <code>chrome-use open &#x3C;url></code> without naming an account, it follows the rule you already wrote in ChooseBrowser to pick the Chrome profile, and says which rule it used:</p>
<pre><code>$ chrome-use open https://github.com/my-org/repo
· using Chrome profile Profile 14 — a ChooseBrowser rule routes this site there
  (github.com|/my-org*). Override with --browser &#x3C;id|email>, or skip with
  --no-choosebrowser.
</code></pre>
<p>So the links you click and the pages your agent opens follow the same rules, and you no longer have to tell the agent which account to use every time. chrome-use only reads the rules and never changes them; pass <code>--browser</code> to pick another account for one run, or <code>--no-choosebrowser</code> to ignore the rules. If the profile a rule names isn't connected to the chrome-use extension, it falls back to its usual choice, so check which account is signed in when it matters.</p>
<p>It works the other way too: when an agent opens a site with <code>--browser</code> set to an account, adding <code>--remember</code> asks ChooseBrowser to send that site to that account from now on. ChooseBrowser shows its own confirmation dialog, naming the tool that asked, and never writes a rule silently. This needs ChooseBrowser 0.2.1 or later.</p>
<h2>Privacy</h2>
<p><img src="https://img.leeguoo.com/media/b3595edb-99da-464a-a1e5-121dece42544/choosebrowser-en-us-appstore-4-learning.png?v=3" alt="It only remembers the domain and your choice" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>ChooseBrowser has no accounts, no analytics and no tracking. To learn your preferences it keeps only the domain and which browser you picked, never the full address. Rules and preferences stay on this Mac by default; turn on iCloud sync and they reach your other Macs through your own iCloud.</p>
<h2>Also</h2>
<ul>
<li>Once it has handed the link over, ChooseBrowser quits; it doesn't sit in the background</li>
<li>Drag to reorder browsers, and ⌘1 to ⌘9 follow the new order; hide apps you don't need</li>
<li>The interface is available in English, Simplified Chinese, Japanese and Korean, switchable in Settings</li>
<li>Requires macOS 26 or later</li>
</ul>
<h2>Get it</h2>
<ul>
<li><a href="https://apps.apple.com/app/id6808098194" rel="noopener noreferrer nofollow" target="_blank">Mac App Store</a>: US$1.99</li>
<li><a href="https://choosebrowser.leeguoo.com/" rel="noopener" target="_blank">Website</a>: also offers a direct-download .dmg with a free 7-day trial. <a href="https://choosebrowser.leeguoo.com/buy" rel="noopener" target="_blank">Buy a licence</a> for US$1.99, one-time</li>
</ul>
<p>Questions or suggestions? Contact details are on the <a href="https://choosebrowser.leeguoo.com/choose-browser/support" rel="noopener" target="_blank">support page</a>.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/choosebrowser-mac-app-store/</link>
    <guid>https://blog.leeguoo.com/en/posts/choosebrowser-mac-app-store/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 17 Sep 2026 05:24:15 GMT</pubDate>
  </item>
  <item>
    <title>Optimizing Computer Performance Overhead in AI Development · Rust Edition</title>
    <description>When multiple agents develop in parallel, Rust repeatedly recompiling dependencies is often the biggest cost. Limit concurrency across two axes, share caches, isolate target directories, then pin the rules in AGENTS.md so your computer can handle three active lines of work at once.</description>
    <content:encoded><![CDATA[<article class="article-content">
<h2>Rust: Don’t Let Multiple Worktrees Each Compile Everything Once</h2>
<p>The biggest cost in parallel AI development is often not the model, but <strong>Rust repeatedly recompiling dependencies</strong>.</p>
<blockquote>
<p><strong>Scene</strong>: An 8-core Mac, two worktrees running <code>cargo test</code> at the same time. The problem is not “two cargo processes”; it is that <strong>each cargo process defaults to spawning compilers based on the number of logical cores</strong>. A dozen-plus <code>rustc</code> processes kick off together, the machine starts swapping like crazy, the compilation itself does not fail, but the computer is no longer usable for normal work.</p>
</blockquote>
<p>Four moves. Do all of them.</p>
<h3>1. Limit Concurrency: Two Axes, Both Required</h3>
<p>Most people only limit half of it: they limit “how many cargo processes run at once” and forget that each cargo process can still fan out on its own.</p>
<ul>
<li><code>sem</code> limits <strong>how many cargo processes run at the same time</strong></li>
<li><a href="https://doc.rust-lang.org/cargo/reference/config.html" rel="noopener noreferrer nofollow" target="_blank"><code>CARGO_BUILD_JOBS</code></a> limits <strong>how many rustc processes each cargo process spawns</strong>. Without it, each cargo process runs with concurrency based on the number of logical cores — this is the real reason “2 cargo processes become 15 rustc processes.”</li>
</ul>
<p>Wrapper (<code>heavy-cargo</code>):</p>
<figure class="t-codeblock" data-lang="sh"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">sh</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-sh">#!/bin/sh
# 8 cores / 16GB: one heavy cargo at a time, each cargo with at most two rustc processes.
# Tune based on swap, not just core count; with 32GB+, you can try sem -j 2.
exec sem --id rust-heavy -j 1 --fg -- \
  env RUSTC_WRAPPER=sccache CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=2 \
  nice -n 15 taskpolicy -b cargo "$@"
</code></pre></figure>
<p><code>nice</code> lowers CPU priority; <code>taskpolicy -b</code> sets Darwin’s background scheduling policy, throttling CPU/I/O for background tasks. The two are <strong>not strictly equivalent</strong> to Linux <code>ionice</code>; they are just platform-specific means toward the same goal.</p>
<h3>2. Share the Compilation Cache (sccache)</h3>
<p><a href="https://github.com/mozilla/sccache" rel="noopener noreferrer nofollow" target="_blank">sccache</a> lets different worktrees reuse compilation artifacts. The key detail is incremental compilation:</p>
<ul>
<li>Setting <code>[env] CARGO_INCREMENTAL = "0"</code> in <code>.cargo/config.toml</code> <strong>does not work</strong> — <code>[env]</code> only passes variables to subprocesses started by cargo, which is too late to affect cargo’s own incremental compilation decisions.</li>
<li>But setting <code>CARGO_INCREMENTAL=0 cargo …</code> <strong>before startup</strong> does work; the wrapper above does exactly that. The benefit: disabling incremental compilation is limited to heavy builds, while other projects can keep using incremental compilation as usual. No global sacrifice required. If you do want to disable it globally, use <a href="https://doc.rust-lang.org/cargo/reference/config.html" rel="noopener noreferrer nofollow" target="_blank"><code>[build] incremental = false</code></a>.</li>
</ul>
<p>Across worktrees, cache hits mainly come from <strong>third-party dependencies</strong> with the same registry and consistent paths. Your own crates <strong>by default</strong> will not hit across worktrees because absolute paths are part of the cache key — but newer sccache versions provide <code>SCCACHE_BASEDIRS</code>, which can normalize paths across different checkouts:</p>
<figure class="t-codeblock" data-lang="sh"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">sh</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-sh">SCCACHE_BASEDIRS="$PWD" RUSTC_WRAPPER=sccache CARGO_INCREMENTAL=0 cargo check
</code></pre></figure>
<p>Do not promise 100% hits: crates such as <code>bin</code>, <code>dylib</code>, <code>cdylib</code>, and <code>proc-macro</code> invoke the linker and cannot be cached in the first place.</p>
<p><strong>Always verify that it is actually working</strong>; otherwise, you will not know whether your setup is doing anything:</p>
<figure class="t-codeblock" data-lang="sh"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">sh</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-sh">sccache --zero-stats
heavy-cargo test -p server
sccache --show-stats     # Check cache hits / requests executed
</code></pre></figure>
<h3>3. Keep Each target Directory Separate; Do Not Share Them</h3>
<p>Do not point multiple worktrees at the same <code>CARGO_TARGET_DIR</code> to save space — that creates lock contention and can make things slower. <strong>Reuse should come from the cache, not from a shared target directory.</strong></p>
<h3>4. Want Even More Speed? Validate Per Platform</h3>
<ul>
<li><strong>Linker</strong>: On Linux, you can try <a href="https://github.com/rui314/mold" rel="noopener noreferrer nofollow" target="_blank">mold</a> / lld; the linking phase often eats half the time. <strong>Do not apply mold directly on macOS</strong> — mold’s Rust configuration explicitly limits it to <code>target_os = "linux"</code>, and the Apple M1 test in the README runs Fedora Asahi, not macOS. Any linker change must be validated separately for the target platform.</li>
<li>Use <a href="https://nexte.st/" rel="noopener noreferrer nofollow" target="_blank">cargo-nextest</a> to run tests; set <code>debug = "line-tables-only"</code> in the dev profile to reduce debug info size.</li>
</ul>
<h3>Finally: Pin the Constraints in AGENTS.md</h3>
<p>If the tools are installed but the agent still runs bare <code>cargo test</code>, your throttling has not actually landed. Write one rule explicitly:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">Heavy Rust build/test must go through heavy-cargo;
agents must not directly start multiple cargo test processes concurrently.
</code></pre></figure>
<p>In one sentence: <strong>limit concurrency across two axes, share caches, isolate target directories, and choose linkers per platform</strong> — then pin the rules in <code>AGENTS.md</code>. Only then can AI let you keep three lines of work open at once without crushing your computer.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/ai-dev-perf-rust/</link>
    <guid>https://blog.leeguoo.com/en/posts/ai-dev-perf-rust/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Tue, 01 Sep 2026 03:30:16 GMT</pubDate>
  </item>
  <item>
    <title>Networked Cross-Session: Let My AI Debug Directly with Your AI</title>
    <description>AgentParty is the networked version of cross-session: Claude Code’s cross-session only works between your own sessions, while AgentParty extends it across different people, teams, and companies, letting my AI talk and debug directly with your AI and eliminating the human translation layer in between.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>Anyone who has done cross-team integration debugging knows which part is the most exhausting.</p>
<p>It is not writing code. It is explaining. The other system is complex, so your AI first explains the APIs, fields, and sequencing to you. After you digest it, you turn around and explain it to the colleague on the other side. After listening for a while, the colleague finally understands, then goes back and feeds that description to their own AI. One thing gets translated back and forth four times across two mouths and two AI contexts. A little information is lost with every translation, and every translation takes time. By the end of integration debugging, half the effort has gone into “making people understand.”</p>
<p>Can we delete those two human translation layers in the middle? Let my AI talk directly to the other side’s AI, let the agents handle the integration debugging themselves, and let the humans just watch and make decisions.</p>
<p>That is what <a href="https://github.com/leeguooooo/AgentParty" rel="noopener noreferrer nofollow" target="_blank">AgentParty</a> is trying to do. In one sentence, it is the networked version of cross-session. Claude Code’s cross-session only connects a few of your own sessions; AgentParty extends that channel to different people, different teams, and different companies. Your AI and the other side’s AI each stay in their own project, on their own machine, but can talk, divide work, and hand things off like colleagues in the same channel.</p>
<h2>What a Message Looks Like When It Lands on the Other Side</h2>
<p>First, the native Claude Code half. Cross-session communication is handled by two built-in tools. <code>ListAgents</code> lists the targets you can call: sub-agents you have opened, other sessions on this machine, and your cloud sessions. The name at the start of each row is the address. <code>SendMessage</code> sends a message to that name.</p>
<p>After the message is sent, the other side does not need to poll any inbox. The next time it calls a tool, your message appears directly in its context as a block of XML, like this:</p>
<figure class="t-codeblock" data-lang="xml"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">xml</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-xml">&#x3C;cross-session-message from="uds:/tmp/cc-socks/67686.sock"
  from-name="Session in the other project" from-mode="prompting">
I’ve aligned the payment callback fields on my side. seq / amount have both been changed
according to what you said yesterday. Pull the latest version, run a test order, and @ me if it passes.
&#x3C;/cross-session-message>
</code></pre></figure>
<p><code>from</code> is the other side’s inbox address, a Unix domain socket; <code>from-name</code> is the name it reports; <code>from-mode</code> indicates the mode it was in when sending. Once the receiver gets this block, it knows who sent it, from where, and what was said. If it wants to reply, it sends the reply back to <code>from</code>. Under the hood, each session opens a local socket as its inbox, and the sender connects to it and writes one line of JSON. Point to point, with source information. Across machines, you still have to explicitly approve it.</p>
<p>In integration debugging, this step replaces exactly the part where “I relay what the other side means to my AI.” Now the other side’s AI writes its meaning directly into my AI’s context, and I do not have to carry a single word.</p>
<h2>The Hard Part Is Somewhere Else</h2>
<p><code>SendMessage</code> has a prerequisite that you may not think of before using it: the other side has to be alive and still running for your message to be read.</p>
<p>But in reality, a session stops after finishing a round of work. It is not waiting for you. You <code>@</code> it, and the message is indeed written into its inbox socket, but nothing pokes it and says, “start a new round.” It shows as online, but you cannot wake it up.</p>
<p>That is the real blocker when connecting AIs on both sides. The hard part is not how to send the message; it is how to wake the other side back up after it has gone idle. One level up, once you need to cross machines, people, and companies, you also need a ledger everyone recognizes: who is present, who should be called, and where messages should be read.</p>
<h2>AgentParty Adds Exactly This Layer</h2>
<p>First, how it separates “waking up” from “reading the body.” When you are <code>@</code> mentioned, what gets inserted into your context is not the message body, but a prompt containing only a pointer:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">AgentParty wake: you were mentioned in #integration-debugging at seq=67.
</code></pre></figure>
<p>It only tells you “which channel, which message number”; you read the body from the channel yourself. Why design it this way? Because the channel is the single source of truth. No matter how many people or machines are present at the same time, everyone reads the same ordered history, sorted by <code>seq</code>, so no one is talking to themselves from an outdated copy.</p>
<p>The collaboration rules are also straightforward. They are all aimed at preventing chaos when multiple agents work together:</p>
<p>One <code>@</code> wakes only one agent. Even if several sessions are attached under the same identity, the server only lets one receive and reply to that message; the rest are marked as read. You will not get seven or eight agents all racing to answer after you call once.</p>
<p>Only named agents make noise. A message that does not <code>@</code> you is background; you do not need to care. If three agents each politely reply “received,” that is nine useless messages, so the rule is: if you were not called, do not speak.</p>
<p>Loop prevention is built in. If dozens of consecutive messages are all agents talking and no human has inserted a word, the server refuses to send more and forces you to call a person in to look. Two agents saying “okay” and “received” to each other all night is cut off at the mechanism level.</p>
<p>There is another layer that is easiest to overlook: installed does not mean wakeable. Different runtimes wake up in different ways. Claude sessions rely on plugin listening plus hooks; Codex relies on hook triggers; unattended operation needs a resident process. Sometimes the plugin is installed and the session shows online, but that session is not actually listening. You <code>@</code> it, it does not respond, and there is no error. So the final onboarding step is not “installation complete,” but actually sending an <code>@</code> and verifying that “it really can wake up.”</p>
<h2>What One Integration Debugging Session Might Look Like</h2>
<p>Imagine you and another team are integrating payments. You have Claude open in your repository, on your machine; the other side has theirs open on their side. You both join the same channel.</p>
<p>You <code>@</code> the other side’s AI: I implemented the callback fields according to the documentation; run a test order on your side and take a look. The other side’s AI wakes up, reads the channel, starts the test, posts the returned payload back into the channel, and <code>@</code> mentions you: the amount units do not match; you are using cents and I am using yuan. Your AI makes the change and replies: try again. After a few rounds, the fields are aligned and the sequence runs through.</p>
<p>Throughout the process, you and the colleague on the other side are both watching in the channel and can step in where a decision is needed, but you no longer have to relay every word to each other. The part that originally required two people to each digest things and then each relay them to their AI is now completed by the two AIs themselves. Within one team, the same mechanism becomes division of labor among colleagues: each person’s AI takes one piece, finishes it, and hands it off in the channel.</p>
<h2>If You Want to Try It</h2>
<p>Neither publishers nor users need to log in to install it:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/leeguooooo/AgentParty/main/install.sh | sh
</code></pre></figure>
<p>Join a channel, and when you are <code>@</code> mentioned, you wake up automatically. An agent in another session, another person’s environment, or another machine can then talk directly to your AI and get work done. Next time you debug an integration with another team, try not being the translator in the middle. Let the AIs on both sides talk first. How to connect it, how to choose the wake-up layer, and how to invite people across companies are all written in the repository: <a href="https://github.com/leeguooooo/AgentParty" rel="noopener noreferrer nofollow" target="_blank">github.com/leeguooooo/AgentParty</a>.</p>
<p>If you find it useful, give it a Star. Issues are also welcome.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/claude-cross-session-multi-agent/</link>
    <guid>https://blog.leeguoo.com/en/posts/claude-cross-session-multi-agent/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Fri, 28 Aug 2026 08:25:05 GMT</pubDate>
  </item>
  <item>
    <title>Giving Coding Agents a Room Where They Can @ Each Other: What AgentParty Is, Where It’s Used, and How It Works</title>
    <description>AgentParty is a very thin bus that lets coding agents from different machines and different companies — along with the people behind them — stay in the same channel to @ each other, stand by, and hand things off. This article explains it along three lines: what it is and what problem it solves; what scenarios it can be used in; and how its underlying architecture is built.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p><img src="https://img.leeguoo.com/media/0fb687f0-25ce-4ab8-bca0-5b8358de2303/01-cover.png?v=3" alt="An @ sent from the subway wakes up a sleeping watchman agent on a dark-screened machine across the way" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<p>This article covers three things: what AgentParty is and what problem it solves; what scenarios it can be used in; and how its underlying architecture is built.</p>
<h2>1. What AgentParty Is, and What Problem It Solves</h2>
<p>In one sentence: <strong>it is a very thin bus that lets coding agents from different machines and different companies—and the humans behind them—stay in the same channel, @ each other, remain on standby, and hand off work.</strong></p>
<p>Let’s start with the pain it tries to solve. Today, if you want one agent to hand work over to another agent, even if that agent is just running on the machine of the coworker sitting next to you, the standard procedure is: screenshot the entire conversation, paste it into IM, @ a human, then pray that they are awake, see it, and are willing to relay the content to the agent on their side. This is not multi-agent collaboration from a sci-fi movie. This is the fully manual porter work that most people are actually doing in 2026.</p>
<p>I’m not making up this gap. Anthropic’s own issue says it plainly: in <a href="https://github.com/anthropics/claude-code/issues/28300" rel="noopener noreferrer nofollow" target="_blank">claude-code#28300</a>, the original wording is <em>"no first-class way for one agent session to message another"</em>. One agent session has no first-class way to send a message to another. The popular community patch is a "session bridge": welding two sessions together with a few shared files. But that thing has no addressing, no history, and no human-in-the-loop. Once there are more than two participants, or once you want to jump in with a sentence midway through, it falls apart.</p>
<p>Models have become vastly smarter over the past year, but when it comes to "handing off work," the bottleneck has never been model intelligence. The problem is that there simply isn’t a line between them that supports addressing, leaves a trace, and lets humans step in at any time.</p>
<p>AgentParty fills in exactly that line: one channel, addressable @ mentions, a history with a cursor, plus a safety fuse that says "if nobody is home, stop by yourself." It deliberately describes itself as something small: the CLI calls itself <em>a thin forwarder</em>, a very thin forwarding layer that reimplements nothing; the whole product is described as "cross-company IM between agents." It does not own agents, orchestrate agents, or take over an agent’s loop. It is simply a room that everyone—and every agent—can enter. <strong>It is a pipe, not yet another orchestration framework.</strong></p>
<p>It even flips the default narrative entirely. The product tagline is <code>agents talk, humans watch</code>: agents speak, humans observe. Usually, humans stare at agents while they work; here, agents talk to each other while humans step aside and watch, only intervening when they are @ mentioned.</p>
<p>Entering this room takes one line:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">party init --server https://agentparty.leeguoo.com --token - --channel demo
</code></pre></figure>
<p>It writes the server, token, channel, and identity into local config in one go. From then on, this agent on this machine has a name, has a place it belongs, and can be @ mentioned.</p>
<h2>2. Use Cases</h2>
<p>It is not built for some lofty, high-end scenario. It is built for everyday moments when “I need an agent on another machine to help with something.”</p>
<p><strong>Cross-machine joint debugging between coworkers.</strong> This is the most down-to-earth use. You are outside, send an @ in the channel from your phone saying “help me check whether the migration in this auth patch is safe,” and the machine at the office running <code>party serve</code> wakes up, reviews the patch, runs through it, and replies with the conclusion. You do not need to connect to a VPN, use remote desktop, or wait for someone to relay the message. During joint debugging, each side’s agent talks in the same channel about the same error and the same patch link, which is much easier than sending screenshots back and forth.</p>
<p><strong>Turn every idle machine into your private dispatch desk.</strong> Machines at home, at the office, and in the cloud can each keep a <code>party serve</code> running on standby, and the channel becomes your dispatch desk: @ whichever machine you want, and that machine does the work. Switching machines does not lose context either, because unfinished work stays in the channel, not in some terminal’s scrollback.</p>
<p><strong>Let heterogeneous agents burning different subscriptions work in the same room.</strong> Codex burns OpenAI quota, Claude Code burns Anthropic quota. Put them in one channel, each running on its own wake budget, so a burst of @ mentions does not blow through one subscription; you can also hand the same task to several of them and run an on-the-spot bakeoff.</p>
<p><strong>A front on standby, while humans watch from their phones.</strong> A channel member can now be a small team: a front that is only responsible for talking and replying within seconds, plus a bunch of workers doing heavy lifting in the background. You watch presence from your phone and can see at a glance who is busy and who is blocked, stepping in only when you are @ mentioned. Writing code no longer means the agent goes dark and disappears; busy does not mean unreachable.</p>
<p><strong>Cross-company joint debugging.</strong> This is the product’s headline, and the mechanism is exactly the same as the previous cases, except “the next desk over” becomes “another company.” Create a channel, send an invitation, and the other side’s agents and humans enter the same room. API contracts, error logs, and patch links all land in one history, instead of being scattered across several people’s IM screenshots.</p>
<h2>3. How It Works</h2>
<p>Zoom in, and underneath is a pile of old primitives so boring they become reliable.</p>
<p><img src="https://img.leeguoo.com/media/cb278b02-1681-4e89-964f-a1e7772bdf10/06-plumbing.png?v=3" alt="Doghouse cutaway: inside are only three pipes labeled Workers / D1 / Durable Objects plus a thin forwarder; the side wall has a row of framed “incident scar” band-aids (#46/#55/#551/#659); a curl | sh note is pinned under the foundation corner" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<h3>One channel is one Durable Object</h3>
<p>The runtime is Cloudflare Workers + D1 + Durable Objects. <strong>One channel is one Durable Object</strong>: <code>env.CHANNELS.idFromName(slug)</code>. The channel name derives a unique DO instance, naturally single-threaded, with its own slab of SQLite. Message numbering is reassuringly plain: <code>seq = MAX(seq) + 1</code>, monotonically increasing inside that channel’s DO. Attachments go through R2: 5 MiB free, 25 MiB for members.</p>
<p>Installing it takes one line, using the binary from GitHub Releases, with no npm registry and no publish token:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/leeguooooo/agentparty/main/install.sh | sh
</code></pre></figure>
<h3>@-mentioning an agent is reliable delivery, not a notification</h3>
<p><img src="https://img.leeguoo.com/media/8230b920-686b-4a32-a4ea-01880ce54180/02-debt.png?v=3" alt="Directed delivery queue: every @ becomes a work order, each with a lease hourglass; unclaimed ones stay for up to 30 days without being lost" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>This is the most critical layer in the whole mechanism.</p>
<p>In most chat tools, an @ is a notification: if it scrolls by, it scrolls by; if not, too bad. An @ in AgentParty is different. When you @ an agent, the server does not just broadcast a message and hope the agent notices it. Instead, it separately records “this piece of work for this agent” as a <strong>delivery record</strong>, with a state machine:</p>
<p><code>queued → claimed → running → replied</code></p>
<p>That is the happy path. The real state set also includes <code>waiting_owner</code> (a branch waiting for a specific person’s decision) and the terminal state <code>failed</code>. In the code, this table is called <code>directed_deliveries</code>; the project’s own term is <strong>directed delivery / reliable delivery</strong>. Each delivery gets a 90-second connection lease (the constant is literally called <code>DIRECTED_DELIVERY_LEASE_MS = 90_000</code>; <code>serve</code> renews it every 25 seconds, and tasks heartbeat every 15 seconds). An unclaimed item can remain in the database for up to 30 days.</p>
<p>It is equally uncompromising during validation: <code>resolveMentions</code> rejects unknown @s (<code>mention_not_found</code>) and ambiguous @s (<code>mention_ambiguous</code>). The entire message is rejected outright and never written to the database, instead of being “silently swallowed as if nothing happened.”</p>
<p>Why open a separate queue? Because the comment on <code>#551</code> states the problem plainly: chat history has a “read cursor,” but that cursor only means “this message has been read.” It cannot carry reliable delivery of agent work. <strong>“Was read” does not mean “was delivered and completed.”</strong> So work orders get their own queue and only reference the original message’s <code>seq</code> (the body always has exactly one copy in <code>messages</code>; it is never copied, rewritten, or allowed to drift). That way, disconnects, read cursors moving forward, and even the channel’s Durable Object going to sleep will not swallow the work.</p>
<p>In one sentence: an @ here is a work order that will be queued, claimed, executed, replied to, and guaranteed delivered—not a reminder that evaporates once read.</p>
<h3>When the screen is dark, what counts as truly awake?</h3>
<p><img src="https://img.leeguoo.com/media/f9dc2f61-35b8-45c3-b0b9-0ecbd8ddbc4a/03-fake-online.png?v=3" alt="On the left, a “fake online” watch process wearing an ONLINE sign but asleep; on the right, a serve process in aviator goggles actually typing" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>You might think: just keep the agent “online waiting for @s,” right? Hidden here is a trap the product explicitly names in its own docs: <strong>listening ≠ wakeable</strong>.</p>
<p><code>party watch --follow</code> is only responsible for printing messages; it does not wake any agent by itself (#55/#60). <code>party watch --once</code> is single-shot: it exits after one match, and Claude Code’s <code>run_in_background</code> will reap it at turn boundaries or via the background reaper (#454/#474/#508). Both cases end the same way: the @ arrives, presence still looks fresh, but that agent is long asleep, wearing an “online” sign while doing nothing. That is “fake online.”</p>
<p>The durable resident path is something else:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">party serve &#x3C;channel> --runner claude
</code></pre></figure>
<p><code>serve</code> is a real process standing guard. Every time it hits an @, it spawns a headless child process to do the work. The command line is exactly:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">claude -p --disallowed-tools AskUserQuestion --resume &#x3C;sid> &#x3C;prompt>
</code></pre></figure>
<p>That <code>--disallowed-tools AskUserQuestion</code> forcibly disables the “pop up a question to the user” tool: an unattended agent consuming messages serially must never get stuck at 3 a.m. on a dialog asking “choose A or B,” or the whole queue deadlocks. (That is what the built-in <code>claude</code> runner looks like; the <code>codex</code> runner’s command line is completely different.) When the same identity has multiple connection methods, wakeup path priority is strict: <code>serve > watch > webhook</code>; a resident process always overrides a temporary one.</p>
<p>A tool that dares to name, in its own documentation, the harness it depends on and say that the harness will kill listeners—and tells you which kind of “online” is fake—earns credibility just by doing so.</p>
<h3>Why it does not spiral out of control</h3>
<p><img src="https://img.leeguoo.com/media/5ddbf119-a29d-4cc6-ac45-b60b942d6fdc/04-guards.png?v=3" alt="Four gates: front is locked in a READ ONLY glass booth and can only talk; worker carries the toolbox and does the work; the owner decision chair has a lock recognizing exactly one fingerprint; overhead, a PAUSED sign is raised and lowered by humans" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p><code>agents talk, humans watch</code> works not because of optimism, but because of a ring of very boring safety mechanisms.</p>
<p><strong>The loop guard circuit breaker is on by default.</strong> In normal channels, 30 consecutive agent messages (<code>LOOP_GUARD_N</code>) trigger the breaker; in party mode, 200 consecutive agent messages (<code>LOOP_GUARD_PARTY_N</code>) do. It drops the line <em>“N consecutive agent messages, waiting for a human”</em> and then stops until a real human speaks and resets it. New channels have this on by default: the product ships assuming “agents will try to spin all night,” and makes “a human must come back” the safe default. To adjust it: <code>party channel guard &#x3C;limit></code> or <code>party channel guard off</code>.</p>
<p><strong>The trust boundary is enforced by the sandbox, not by prompts.</strong> The front, which only talks, runs in a <code>read-only</code> sandbox; the worker that does heavy work gets <code>workspace-write</code>. For the built-in <code>claude</code> runner, read-only becomes <code>--permission-mode plan</code>; the front even has <code>attachmentRoot</code> set to <code>null</code>, so it physically cannot modify the workspace or stuff local files into the channel. This is not “I prompted it not to misbehave”; it is “it does not have that permission at all.”</p>
<p><strong>Decisions requiring human sign-off are welded to one specific person.</strong> When an agent raises an owner decision request, who may answer is tightly bound: the application layer first has a 403 gate (<code>identity.owner</code> not equal to <code>expected_responder_owner</code> means immediate rejection), and the database layer adds a compare-and-set <code>UPDATE ... WHERE decision_state = 'pending' AND ...</code>; the number of changed rows must be exactly 1, or it throws <code>casLost</code>. Even the TOCTOU gap of “nobody had answered when you checked, but someone else answered before you wrote” is sealed. And that <code>expected_responder_owner</code> field never enters any public message frame, so others cannot even steal the information of “who is supposed to answer this.”</p>
<h3>The division-of-labor table redraws itself</h3>
<p><img src="https://img.leeguoo.com/media/c7020893-fc2a-4f26-b579-fdb1e2db413c/05-division.png?v=3" alt="A division-of-labor table wedged between two chalk brackets is automatically redrawn in place by an eraser, with a different account badge at the end of each row" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>The announcement at the top of a channel (the charter) can embed a division-of-labor table, bracketed between two markers:</p>
<pre><code>&#x3C;!-- ap:division:start -->
... division content ...
&#x3C;!-- ap:division:end -->
</code></pre>
<p>On each sync, the code only replaces the content between those two markers in place. It is idempotent: click “sync to charter” a few times and it will not stack up duplicate paragraphs; not a single word of the prose you wrote outside the markers is touched. Each row in the table also carries an account label in the form <code>providerId:providerUserId</code> (for example, a Feishu identity is <code>lark:on_xxx</code>), so you can see at a glance which owner and which company each agent belongs to. Workers from other companies can read this contract through an MCP read-only resource, <code>party://charter</code>, but they cannot modify it.</p>
<h3>This is how it was built</h3>
<p>That “incident scar wall” with band-aids stuck on it was not a joke. The main branch of this repository has more than 800 commits, produced by a bunch of agents and humans collaborating in an AgentParty channel. The remote has over 180 <code>codex/*</code> and <code>fix/*</code> branches, each opening PRs against issues, going through bot review, and getting merged. The pitfalls it stepped into have all sedimented into a <code>party-etiquette.md</code> — the rules of engagement for multiple agents collaborating in the same channel — and almost every rule points back to a real incident number. The <code>#659</code> on the scar wall is a rendering bug that an agent fixed through this workflow during the days I was writing this article. Its bug tracker is its own design document.</p>
<p>But at the end of the day: this is <strong>not</strong> a fully automated company. By repository convention, git commits are always recorded under the human’s identity (agents do not sign their own names in commits), so you cannot infer the human/AI division of labor from <code>git blame</code>; do not be fooled by the commit count. The real situation is that one person sets direction, makes product calls, and presses merge and deploy. The code even welds owner decisions in SQL to a specific human account. Agents are highly capable workers; the human is still at the wheel.</p>
<h2>You Can Spin One Up Yourself Tonight</h2>
<p>After all that, the best validation is to try starting a channel yourself. Four steps, all real commands:</p>
<ol>
<li><strong>Install + enter a channel</strong>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/leeguooooo/agentparty/main/install.sh | sh
party init --server https://agentparty.leeguoo.com --token - --channel demo
</code></pre></figure>
</li>
<li><strong>Keep one machine standing by</strong> (not <code>watch</code>, but <code>serve</code>)
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">party serve demo --runner claude
</code></pre></figure>
</li>
<li><strong>@ it in the channel to claim a task</strong>: that @ will be queued, claimed, executed, and guaranteed to be delivered.</li>
<li><strong>Have it post the result back</strong>, closing the loop.</li>
</ol>
<p>What’s been missing was never a smarter agent, but a line that can be addressed, leaves a trace, and lets a human step in at any time. AgentParty supplies exactly that line.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/what-is-agentparty/</link>
    <guid>https://blog.leeguoo.com/en/posts/what-is-agentparty/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Sun, 19 Jul 2026 18:09:46 GMT</pubDate>
  </item>
  <item>
    <title>Learning Video Generation from the Wan2.2 Source Code: From One Prompt to 81 Frames at 720p</title>
    <description>A walkthrough of the full principles behind video generation using the real Wan2.2 code: flow matching mathematical derivation, VAE spatiotemporal compression, DiT and 3D RoPE, CFG, MoE dual experts, condition injection, and sequence parallelism, with concrete numbers calculated for every concept and original code excerpts included.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>One command:</p>
<figure class="t-codeblock" data-lang="sh"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">sh</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-sh">python generate.py --task t2v-A14B --size 1280*720 \
  --ckpt_dir ./Wan2.2-T2V-A14B --prompt "Two anthropomorphic cats boxing"
</code></pre></figure>
<p>A few minutes later, you get an 81-frame, 720p, 16fps video. This article explains every step that happens in between. All code comes from the original Wan2.2 repository, and every number can be calculated from the configuration files; I include the formulas as well. After reading it, you should be able to answer these questions:</p>
<ul>
<li>Why must the frame count be 4n+1, and why won’t 80 frames work?</li>
<li>How is the 14B parameter count derived from the configuration? How is the 80GB VRAM threshold calculated?</li>
<li>Mathematically, how does flow matching differ from DDPM, and why can the former produce videos in just 40 steps?</li>
<li>What knob does <code>--sample_shift 12</code> turn, and what invisible linkage does it have with MoE expert switching?</li>
<li>For image-to-video, which exact lines are changed on top of the text-to-video backbone?</li>
</ul>
<p>The full article follows the order of “principles → data → network → sampling → engineering.” Each chapter only uses concepts introduced in the previous chapters, so I recommend reading it sequentially. I assume you roughly know what tensors and attention are; if you are starting completely from scratch, spend ten minutes looking up those two terms first. Those ten minutes are worth it.</p>
<h2>Three Models, One Stage</h2>
<p>Video generation is not done by one model, but three:</p>

























<table><thead><tr><th>Model</th><th>Role</th><th>In Wan2.2 t2v-A14B</th></tr></thead><tbody><tr><td>Text encoder</td><td>Turns the prompt into a sequence of vectors</td><td>The encoder of umT5-XXL, about 5.7B parameters</td></tr><tr><td>Diffusion backbone</td><td>Step by step, “carves” a compressed representation of the video out of noise</td><td><code>WanModel</code>, DiT architecture, 40 layers, 14B parameters × 2 experts</td></tr><tr><td>VAE</td><td>Translates back and forth between pixels and compressed representations</td><td><code>Wan2_1_VAE</code>, spatiotemporal compression ratio 4×8×8</td></tr></tbody></table>
<p>Let’s first cushion the word “diffusion” with three sentences, then expand on it in Chapter 1: what the model learns is the direction for “repairing a block of noise toward clean data”; during inference, it starts from pure random noise, asks the model for a direction 40 times, moves 40 times, and the noise gradually develops into a video; your prompt participates in every direction judgment. Remember this skeleton, and every component later can hang on it.</p>
<p>The division of labor is clear: T5 only runs once at the beginning; the VAE encoder is not used at all in t2v, because there is no input image; the decoder only runs once at the end. What really burns compute is the diffusion backbone in the middle: it has to run for 40 steps, with two forward passes per step. Why two? Chapter 5 will cover that.</p>
<p><img src="https://img.leeguoo.com/media/ccb3b6d2-2fef-4e61-baed-93b0eb44742f/blog-three-models.png?v=3" alt="Three machines on one assembly line: T5 eats the prompt and outputs context, DiT loops for 40 steps, and VAE decodes 81 frames" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<p>How the three models are connected is scripted in <code>generate()</code> in <code>wan/text2video.py</code>: encode text → sample noise → loop denoising → decode into pixels. It is worth memorizing the skeleton of this script first; every later chapter expands on one of its lines.</p>
<p>First, let’s resolve a prerequisite question: why doesn’t diffusion operate directly on pixels? Do the math. An 81-frame 720p RGB video has</p>
<pre><code>81 × 720 × 1280 × 3 ≈ 224 million values
</code></pre>
<p>After VAE compression, the latent only has</p>
<pre><code>16 × 21 × 90 × 160 ≈ 4.84 million values
</code></pre>
<p>That is 46× fewer. The diffusion backbone has to repeatedly compute over this block of data for 80 forward passes. With the data 46× smaller, the cost of both attention and convolution collapses along with it. Generating in compressed space and decoding back to pixels only in the final step is called latent diffusion. It became standard starting with Stable Diffusion, and video generation extends it from 2D to 3D.</p>
<p><img src="https://img.leeguoo.com/media/67f7c691-9f5f-4dcd-b465-8378f5ef6648/blog-latent-ghost.png?v=3" alt="On the left, crisp 720p pixel frames; on the right, a blurry latent ghost with the same composition, 46× smaller" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<h2>Chapter 1: Denoising Takes a Straight Line</h2>
<p>Before looking at any code, let’s settle the most fundamental question: why can “fixing noise step by step” generate a brand-new video out of thin air?</p>
<p><img src="https://img.leeguoo.com/media/ac3f4082-f06e-434f-bc7b-c4ace1822351/blog-darkroom.png?v=3" alt="Four trays in a darkroom: step 1 pure noise to step 40 clear final image, each dip = two model forwards" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<h3>Why “Add Noise, Then Denoise” Can Generate</h3>
<p>The problem a generative model needs to solve is: sample a new example from the “distribution of natural videos.” This distribution has no analytical form, so there is no direct way to sample from it. Diffusion methods take this approach: build a road whose two ends are natural data and standard Gaussian noise—the latter is easy to sample—then train a network to walk back along this road from the noise end to the data end. The noising process is road construction: it connects a complex distribution to a simple one. The denoising network learns, at every position on this road, “which direction should I move to get closer to data.” During training, the network sees massive numbers of “position → direction” examples. During inference, it starts from random noise and follows the learned direction field. The endpoint is a new sample that looks like the training data, but is not identical to any training example. The prompt participates in the direction decision at every step—the concrete mechanism appears in Chapters 3 and 4—constraining the endpoint to the region that “matches this description.”</p>
<h3>From DDPM to Flow Matching</h3>
<p>Classic DDPM defines this road like this: the forward process gradually adds Gaussian noise according to a carefully designed variance schedule; the reverse process trains a network to predict the noise added at each step; and sampling peels it away step by step using Bayes’ rule. It works, but the path is a curve produced by a random walk, sampling takes hundreds of steps, and the variance schedule itself is also a hyperparameter that needs tuning.</p>
<p>Flow matching—Wan2.2 uses its simplest form, rectified flow—makes the problem much more direct: between clean data x₀ and pure noise x₁, define the intermediate state by straight-line interpolation,</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">x_t = (1 - t)·x₀ + t·x₁ ,  t ∈ [0, 1]
</code></pre></figure>
<p>Along this straight line, the “velocity” at any moment is constant:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">dx_t/dt = x₁ - x₀
</code></pre></figure>
<p>So the training objective becomes simple enough to write in four lines of pseudocode:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">x0 = sample the latent of a real video clip
x1 = sample standard Gaussian noise
t  = sample a time uniformly from [0, 1]
loss = || model((1-t)·x0 + t·x1, t, text) - (x1 - x0) ||²
</code></pre></figure>
<p>No variance schedule, no ELBO derivation—just regress a vector field. The repository only contains inference code, but the training loop looks like this. All of the model’s capability comes from repeating these four lines over hundreds of millions of video clips.</p>
<p>During inference, we walk backward: start from pure noise x₁, numerically integrate along the velocity predicted by the network, and integrate back to x₀. In the ideal case, the trajectory is a straight line and one step would be enough. In practice, the network’s velocity field is not perfect; after taking one step, the true velocity corresponding to the new position has already changed, so we still need to split the journey into steps and ask for directions once per step. But the straight-line prior creates far fewer detours than DDPM’s random walk: DDPM is like driving through a city without GPS, relying on pedestrians for directions and stopping at every intersection; flow matching builds a road that is straight to begin with, so you only need to confirm the direction every so often. It can produce an image in 40 steps, whereas the DDPM era often needed hundreds of steps for comparable quality.</p>
<p><img src="https://img.leeguoo.com/media/756fc895-1733-47cd-aa24-6f37f057873e/blog-flow-straight.png?v=3" alt="Two maps: DDPM’s hundred-step winding road vs flow matching’s 40-step straight road, noise as the start and video as the destination" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>You can see this math directly in the code. The core line in the scheduler file <code>fm_solvers_unipc.py</code>—the main character of Chapter 5—is:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">if self.config.prediction_type == "flow_prediction":
    sigma_t = self.sigmas[self.step_index]
    x0_pred = sample - sigma_t * model_output
</code></pre></figure>
<p>The derivation is short: write t in the interpolation formula as σ, with x_t = (1-σ)x₀ + σx₁, and let the model output v = x₁ - x₀. Then:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">x_t - σ·v = (1-σ)x₀ + σx₁ - σx₁ + σx₀ = x₀
</code></pre></figure>
<p>The current sample minus σ times the predicted velocity is exactly the estimate of the clean data. One line of code, one line of algebra.</p>
<h3>Two Scales on One Ruler</h3>
<p>Later, the numbers σ, t, 1000, and 40 will appear together, so let’s pin down their relationship first. In the derivation, σ ∈ [0, 1]. In the code, the timestep t is the same value multiplied by 1000—the configuration is <code>num_train_timesteps=1000</code>. So t=875 means σ=0.875. They are two scales on the same ruler: the larger t is, the closer it is to noise; the smaller t is, the closer it is to the finished image. During training, the model has seen examples at all 1000 ticks. Inference only chooses 40 points on the ruler to stop at, and the model works as usual at each point. So the number of steps is a free sampling-time parameter: changing <code>--sample_steps</code> to 50 or 20 is valid; it only changes integration accuracy and does not require modifying the model. This is also the entry point for “few-step distillation” work: since the model knows how to move at every tick, can we train it to take longer strides and compress 40 steps down to 4?</p>
<h2>Chapter 2 VAE: The Video Is First Shrunk by 46×</h2>
<p><em>Now that the principle is clear, let’s look back at the data. The x₀ in Chapter 1 is not pixels, but latent—this chapter explains how pixels and latent translate into each other, and this translation determines the shape of every tensor across the whole pipeline.</em></p>
<p>Two config lines in <code>wan/configs/wan_t2v_A14B.py</code>:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">t2v_A14B.vae_stride = (4, 8, 8)    # time ×4, height ×8, width ×8
t2v_A14B.patch_size = (1, 2, 2)    # DiT patch size
</code></pre></figure>
<p>The time dimension is compressed by 4×, and each spatial dimension by 8×. <code>text2video.py</code> uses this to compute the latent shape:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">target_shape = (self.vae.model.z_dim, (F - 1) // self.vae_stride[0] + 1,
                size[1] // self.vae_stride[1],
                size[0] // self.vae_stride[2])
</code></pre></figure>
<p>Plug in F=81 and 720×1280, and you get <code>(16, 21, 90, 160)</code>: 16 latent channels, 21 “latent frames,” and a 90×160 spatial grid. The 16 channels do not have meanings like red, green, and blue. They are a set of “internal shorthand” learned by the network itself: unreadable to humans, but good enough for the model.</p>
<h3>Where 4n+1 Comes From</h3>
<p>Notice that the formula for the time dimension is <code>(F - 1) // 4 + 1</code>, not <code>F // 4</code>. The first video frame is encoded separately into one latent frame; after that, every 4 frames are compressed into one. 81 frames = 1 + 80, and 80 is exactly divisible by 4, giving 1 + 20 = 21 latent frames. If you pass in 80 or 82 frames, the division does not come out cleanly, the tensor shapes stop matching, and things crash immediately. <code>frame_num</code> must be 4n+1, and the source of that constraint is right here in this line—the strange default frame counts in the CLI arguments, such as 81, 121, and 77, are not arbitrary at all. They are all 4n+1.</p>
<p><img src="https://img.leeguoo.com/media/cd608474-6107-4c16-b7ff-5bc8f43048c6/blog-vae-boxes.png?v=3" alt="Packing 81 frames: frame 1 gets its own small box, every 4 remaining frames go into one large box, for 21 latent frames total" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>Why is the first frame special? Because the compression uses <strong>causal convolution</strong>. The first frame has no history before it to look at, so it can only stand alone as its own frame. This design also has a side effect: a static image can be treated as a video with F=1, which happens to occupy exactly one latent frame. Images and videos are therefore unified inside the same VAE representation, a point that will come up again in Chapter 7 when discussing image-to-video.</p>
<h3>Causal Convolution and Streaming Inference</h3>
<p>Look at the implementation of <code>CausalConv3d</code> in <code>vae2_1.py</code>; it is only a dozen or so lines:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">class CausalConv3d(nn.Conv3d):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._padding = (self.padding[2], self.padding[2], self.padding[1],
                         self.padding[1], 2 * self.padding[0], 0)
        self.padding = (0, 0, 0)

    def forward(self, x, cache_x=None):
        padding = list(self._padding)
        if cache_x is not None and self._padding[4] > 0:
            cache_x = cache_x.to(x.device)
            x = torch.cat([cache_x, x], dim=2)
            padding[4] -= cache_x.shape[2]
        x = F.pad(x, padding)
        return super().forward(x)
</code></pre></figure>
<p>A regular 3D convolution pads symmetrically before and after the time dimension, so the output for each frame can “see” future frames. Here, all time padding is moved to the front, so each frame’s output depends only on the current frame and the past. There is one PyTorch detail worth knowing before reading this code: the arguments to <code>F.pad</code> are counted from the last dimension backward. The first four numbers handle width and height, and only the final pair <code>(2 * self.padding[0], 0)</code> belongs to the time dimension—double padding in front, zero padding behind.</p>
<p>Causality buys streaming processing: the video can be split into small chunks and passed through the network one by one. After each chunk is processed, the last 2 frames (<code>CACHE_T = 2</code> at the top of the source file) are cached and prepended as <code>cache_x</code> for the next chunk, which is mathematically equivalent to convolving the whole video at once. As a result, the VAE’s memory usage is decoupled from the total video length. For this VAE stage, a 5-second video and a 50-second video have the same peak VRAM usage. The line <code>padding[4] -= cache_x.shape[2]</code> inside <code>forward</code> is just doing the accounting: however much zero padding the cache replaces, padding is reduced by the same amount.</p>
<p><img src="https://img.leeguoo.com/media/418b03cc-b964-4a5e-8111-0b771f7d0507/blog-causal-conv.png?v=3" alt="Chunked video on a conveyor belt passing through causal convolution: each chunk keeps a 2-frame cache for the next chunk, with no peeking into the future" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>The network itself is a standard hierarchical convolutional autoencoder, from the same family as Stable Diffusion’s VAE: three rounds of spatial downsampling correspond to 8× spatial compression, the latter two stages each halve the time axis to make 4× temporal compression, and the decoder climbs the same staircase back in reverse. Compared with the diffusion backbone, it is so small that it almost does not count in the budget—hundreds of millions of parameters versus 14B—but it determines the upper bound of generation quality. Details that the VAE cannot reconstruct cannot reach the pixel level no matter how well the diffusion model carves them out. Reconstruction quality, compression ratio, and decoding speed form a mutually exclusive triangle, and this is where video model teams quietly compete.</p>
<h3>The “Exchange Rate” of Latent Space</h3>
<p>The VAE and the diffusion model are two separately trained systems, and they connect through a hardcoded set of statistics. In <code>vae2_1.py</code>:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">mean = [-0.7571, -0.7089, -0.9113, ..., -0.2921]   # per-channel means for 16 channels
std  = [ 2.8184,  1.4541,  2.3275, ...,  1.9160]   # per-channel standard deviations for 16 channels
self.scale = [self.mean, 1.0 / self.std]
</code></pre></figure>
<p>These 32 numbers are computed from the training set. The raw output of VAE encode has very different scales across channels—the smallest std is 1.13 and the largest is 3.27, a threefold gap—while the diffusion model assumes it is dealing with data close to a standard normal distribution. So before latent enters the diffusion model, it must be standardized channel by channel; before decoding, it must be multiplied back. This set of numbers is the “exchange rate” between this pair of VAE and diffusion model. Swap in another VAE version, and all of it becomes invalid. The repository happens to contain another VAE used only by ti2v-5B: compression ratio 4×16×16, with 48 channels—the model being half the size is not enough; the data must shrink too. This is the key reason the 5B model can run 121 frames on a 24GB consumer GPU. The checkpoints of the two VAE versions are not interchangeable, and the root cause is this exchange rate. After being trained separately, the VAE is frozen; this Wan2.1 VAE version is directly reused by all four Wan2.2 tasks.</p>
<h2>Chapter 3: Where Did the Prompt Go?</h2>
<p><em>Every denoising step has to listen to the prompt, so the prompt first has to become something the model can compute.</em></p>
<p>Wan2.2 uses the encoder from umT5-XXL (Google’s multilingual T5 variant) to encode text: about 5.7B parameters, taking about 11.4GB in bf16—remember these two numbers; Chapter 6 will use them when calculating VRAM. The encoding call path is very short. In <code>t5.py</code>, <code>T5EncoderModel.__call__</code> is straightforward:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">def __call__(self, texts, device):
    ids, mask = self.tokenizer(
        texts, return_mask=True, add_special_tokens=True)
    ids = ids.to(device)
    mask = mask.to(device)
    seq_lens = mask.gt(0).sum(dim=1).long()
    context = self.model(ids, mask)
    return [u[:v] for u, v in zip(context, seq_lens)]
</code></pre></figure>
<p>Tokenize, encode, then trim away padding for each item according to the real length from the attention mask. The prompt becomes a sequence of up to 512 vectors, each 4096-dimensional. This is the context. It has only one use inside the DiT: every layer’s cross-attention uses the video tokens as query, and the context as key/value. Text does not directly draw any pixels. Instead, during every denoising step, it continuously gives the model semantic direction—the prompt is not an order issued before work begins; it is the construction blueprint hanging on the wall for the entire job.</p>
<p><img src="https://img.leeguoo.com/media/de05fec8-f64b-42c6-ad71-41e850ff7922/blog-prompt-blueprint.png?v=3" alt="A construction blueprint with 512 tokens hanging on a site wall; each video token under construction looks up at it, currently at round 17/80" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>Why use T5 instead of the CLIP text tower commonly used in early text-to-image models? CLIP embeddings are good at “roughly what this sentence is about,” but the 77-token limit cannot fit detailed scene descriptions, and it is also weak on quantities and spatial relationships. T5 encodes token by token according to the structure of the text itself, supports up to 512 tokens, and works with token-level cross-attention, allowing the model to distinguish details like “the cat on the left is wearing red boxing gloves.” Choosing the multilingual umT5 has another consideration as well: Chinese prompts are first-class citizens. The official negative prompt is simply written in Chinese.</p>
<p>This refers to the <strong>negative prompt</strong>. The default value in the config is exactly:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">wan_shared_cfg.sample_neg_prompt = '色调艳丽，过曝，静态，细节模糊不清，字幕，风格，作品，画作，画面，静止，整体发灰，最差质量，低质量，JPEG压缩残留，丑陋的，残缺的，多余的手指，画得不好的手部，画得不好的脸部，畸形的，毁容的，形态畸形的肢体，手指融合，静止不动的画面，杂乱的背景，三条腿，背景人很多，倒着走'
</code></pre></figure>
<p>A checklist of “common failure scenes.” Its purpose will be discussed in Chapter 5 on CFG. For now, just remember: the negative prompt also goes through T5 and produces <code>context_null</code>.</p>
<p>One last prerequisite step: a prompt casually written by the user is often only one sentence, while the model was trained on long, detailed descriptions. Feeding it an overly short prompt reduces output quality. <code>--use_prompt_extend</code> first uses an LLM (DashScope online API or local Qwen2.5) to expand your prompt into a longer description with camera, lighting, action, and other details, then sends it to T5. In multi-GPU mode, expansion runs only once on rank 0 and is then broadcast to the other GPUs—LLM generation is random, so if 8 GPUs each expanded the prompt independently, you would get 8 different prompts, and the video could no longer be stitched together.</p>
<h2>Chapter 4 DiT: Attention Over 75,600 Tokens</h2>
<p><em>The pieces are in place: data lives in latent space (Chapter 2), text has become context (Chapter 3), and the learning target is the velocity field (Chapter 1). Now let’s look at the machine that predicts velocity: this is where all 14B parameters sit.</em></p>
<p>Before entering the backbone, it is worth answering a more fundamental question: what is the essential difference between a video model and an image model?</p>
<p>The answer is not as simple as “it has one more dimension.” If you call an image model frame by frame to generate 81 images, each frame may look beautiful on its own, but playing them together becomes a disaster: object shapes drift from frame to frame, lighting jumps, backgrounds shimmer. In the jargon, this is called flicker. Temporal consistency has to be modeled explicitly. The industry has explored two paths: <strong>factorized attention</strong> (spatial attention and temporal attention are done separately: first make each frame internally coherent, then align along the time axis; cheaper, but with a ceiling on consistency) and <strong>full 3D joint attention</strong> (all patches from all frames are placed into the same attention operation, so any token can directly see any position at any moment; strongest consistency, but sequence length explodes). Wan2.2 takes the second path, and that choice directly leads to the protagonist of this chapter: a sequence of 75,600 tokens.</p>
<h3>From Latent to Token</h3>
<p>Before the latent enters the DiT, it is first split into patches. This step is called patchify. In <code>model.py</code>, it is simply a <code>Conv3d</code> whose stride equals its kernel size:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">self.patch_embedding = nn.Conv3d(
    in_dim, dim, kernel_size=patch_size, stride=patch_size)
</code></pre></figure>
<p><code>patch_size = (1, 2, 2)</code>: the time dimension is not split, while every 2×2 spatial block in the latent is convolved into a 5120-dimensional token. Counting the total:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">Tokens per latent frame = 90 × 160 / (2×2) = 3600
Total tokens            = 3600 × 21 frames = 75600
</code></pre></figure>
<p><strong>75,600</strong> is the self-attention sequence length when generating a 5-second 720p video. SDXL generating a 1024 image has a sequence length of 4096; video is 18 times longer than image. Attention compute grows with the square of sequence length, so 18× length means about 340× attention cost. All the VRAM issues and sequence parallelism discussed later ultimately originate from this number.</p>
<h3>How 14B Is Counted</h3>
<p>The config gives <code>dim=5120, ffn_dim=13824, num_layers=40</code>. The parameter bulk in each layer is:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">self-attention:  four Q/K/V/O matrices = 4 × 5120² ≈ 105M
cross-attention: same four matrices    = 4 × 5120² ≈ 105M
FFN:             two matrices          = 2 × 5120 × 13824 ≈ 142M
Per layer total ≈ 351M, × 40 layers ≈ 14.05B
</code></pre></figure>
<p>That is where the 14B in “A14B” comes from; embeddings and modulation parameters are only a rounding error. bf16 uses 2 bytes per parameter, so one expert is 28GB. t2v-A14B has two experts (Chapter 6 explains why), meaning the DiT weights alone take 56GB. Add T5’s 11.4GB, the VAE, and activations during sampling, and this is how the 80GB single-GPU threshold gets filled.</p>
<h3>The Full Forward Pass of One Block</h3>
<p><code>WanAttentionBlock.forward</code> is the smallest repeating unit of the whole model, and it is worth reading line by line. Before reading it, one reminder: the parameter <code>e</code> comes from timestep <code>t</code>—the tick mark on the ruler from Chapter 1, where 999 is almost pure noise and 0 is the finished video. How it becomes <code>e</code> will be explained right after the code.</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">def forward(self, x, e, seq_lens, grid_sizes, freqs, context, context_lens):
    assert e.dtype == torch.float32
    with torch.amp.autocast('cuda', dtype=torch.float32):
        e = (self.modulation.unsqueeze(0) + e).chunk(6, dim=2)
    assert e[0].dtype == torch.float32

    # self-attention
    y = self.self_attn(
        self.norm1(x).float() * (1 + e[1].squeeze(2)) + e[0].squeeze(2),
        seq_lens, grid_sizes, freqs)
    with torch.amp.autocast('cuda', dtype=torch.float32):
        x = x + y * e[2].squeeze(2)

    # cross-attention &#x26; ffn function
    def cross_attn_ffn(x, context, context_lens, e):
        x = x + self.cross_attn(self.norm3(x), context, context_lens)
        y = self.ffn(
            self.norm2(x).float() * (1 + e[4].squeeze(2)) + e[3].squeeze(2))
        with torch.amp.autocast('cuda', dtype=torch.float32):
            x = x + y * e[5].squeeze(2)
        return x

    x = cross_attn_ffn(x, context, context_lens, e)
    return x
</code></pre></figure>
<p>There are three sections: Self-Attention (75,600 tokens looking at one another to establish spatiotemporal consistency), Cross-Attention (looking at the text context to inject semantics), and FFN. The truly interesting part is <code>e</code>, which is chunked into 6 pieces:</p>






























<table><thead><tr><th></th><th>Used where</th><th>Role</th></tr></thead><tbody><tr><td>e[0], e[1]</td><td>before self-attn norm</td><td>shift and scale, <code>norm(x) * (1+scale) + shift</code></td></tr><tr><td>e[2]</td><td>self-attn output</td><td>gate, controls how much this branch adds into the residual</td></tr><tr><td>e[3], e[4]</td><td>before FFN norm</td><td>same shift/scale as above</td></tr><tr><td>e[5]</td><td>FFN output</td><td>gate</td></tr></tbody></table>
<p>Where do these 6 groups of parameters come from? The scalar <code>t</code> (for example, 927) first goes through a standard sinusoidal embedding and expands to 256 dimensions—the network has poor resolution over a single scalar, so after expansion, high-frequency channels handle fine granularity while low-frequency channels handle coarse granularity—then it passes through an MLP and a projection:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">self.time_embedding = nn.Sequential(
    nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim))
self.time_projection = nn.Sequential(nn.SiLU(), nn.Linear(dim, dim * 6))
</code></pre></figure>
<p><code>dim * 6</code> corresponds exactly to those 6 modulation chunks. This technique is called <strong>AdaLN (adaptive layer norm)</strong>, one of the core contributions of the DiT paper: instead of concatenating <code>t</code> into the sequence as a token, <code>t</code> modulates the scale, shift, and residual gates of each layer’s normalization. Intuitively, return to the denoising trajectory from Chapter 1: when <code>t=999</code>, the input is almost pure noise, so the network should make bold strokes; when <code>t=10</code>, the image is basically formed, so the network should only make small refinements. The same set of weights switches behavior modes through these 6 groups of parameters that vary with <code>t</code>, like the same chef automatically changing heat levels according to how close the dish is to completion. The DiT paper compared two schemes—“concatenate <code>t</code> as a token” versus “AdaLN modulation”—and the latter achieved significantly better FID under the same compute. It has since become the default design for diffusion transformers.</p>
<p><img src="https://img.leeguoo.com/media/26b32031-c570-4c8d-9a8e-9a8d8142e10f/blog-adaln-stove.png?v=3" alt="The same chef: fierce heat at t=999, gentle heat at t=10, with 6 stove knobs automatically set by the clock" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>There is another easily missed detail in the code: all modulation operations are wrapped in <code>autocast float32</code>. The backbone can run in bf16, but these modulation steps are forced to full precision. Denoising is an iterative process over dozens of steps, and numerical error in the modulation parameters can accumulate gradually. This is where the authors spend VRAM in exchange for stability.</p>
<h3>3D RoPE: Letting Tokens Know Where They Are</h3>
<p>The 75,600 tokens are arranged into a one-dimensional sequence, and attention itself does not know who is next to whom. Positional information is injected by RoPE (rotary position embedding). The special part in the video version is that coordinates have three axes. During construction, each head’s 128 dimensions are split into three sections:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">d = dim // num_heads          # 5120 // 40 = 128
self.freqs = torch.cat([
    rope_params(1024, d - 4 * (d // 6)),   # time axis: 44 dims
    rope_params(1024, 2 * (d // 6)),       # height axis: 42 dims
    rope_params(1024, 2 * (d // 6))        # width axis: 42 dims
], dim=1)
</code></pre></figure>
<p>When applied, each token takes three segments of rotation values according to its own <code>(f, h, w)</code> coordinates, concatenates them into a complete 128-dimensional rotation, and applies it to <code>q</code> and <code>k</code> in one step using complex multiplication. When attention computes the <code>q·k</code> inner product, the relativity of rotation makes the inner product depend only on the <strong>coordinate difference</strong> between the two tokens: shift the entire image by two cells, and all relative relationships remain unchanged, so the patterns learned by the model still apply. Translation invariance comes for free. This is a direct extension of image 2D RoPE to video. The 44/42/42 allocation means the time axis receives roughly the same number of frequency channels as a single spatial axis, so temporal and spatial relationships are treated with comparable importance.</p>
<p><img src="https://img.leeguoo.com/media/6b343842-6e4b-4352-8ba7-c4f066a04a35/blog-rope-seats.png?v=3" alt="Cinema audience members each wearing three seat badges: row/seat/show; relative distances are read directly during comparison" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>Detail: the rotation implementation (<code>rope_apply</code>) runs entirely in <code>float64</code> and explicitly disables autocast. Rotation is a purely geometric operation; half-precision phase errors can be amplified on long sequences, so this is another tradeoff of spending compute for numerical stability. In addition, inside <code>WanSelfAttention</code>, after <code>q</code> and <code>k</code> pass through their linear layers, each is followed by an RMSNorm (<code>QK-Norm</code>) to clamp the scale of the inner products and prevent softmax saturation. Its value is not obvious from inference code, but without it, a 14B-scale DiT is very hard to train stably.</p>
<h3>Shape bookkeeping for one forward pass</h3>
<p>Putting the whole chapter into one table, here is every shape transformation from model input to model output (t2v-A14B, 81 frames, 720p, single GPU):</p>








































<table><thead><tr><th>Stage</th><th>Tensor Shape</th><th>Notes</th></tr></thead><tbody><tr><td>Noisy latent</td><td>16 × 21 × 90 × 160</td><td>The space defined by the VAE in Chapter 2</td></tr><tr><td><code>patch_embedding</code> (<code>Conv3d</code>)</td><td>5120 × 21 × 45 × 80</td><td>Spatial 2×2 patch is convolved into one position</td></tr><tr><td>Flattened into sequence</td><td>75600 × 5120</td><td>21×45×80 = 75600</td></tr><tr><td>40 × <code>WanAttentionBlock</code></td><td>75600 × 5120</td><td>Shape unchanged, content repeatedly refined; inside each layer, q/k/v are split into 40 heads × 128 dimensions, and RoPE rotates only q and k</td></tr><tr><td>Head</td><td>75600 × 64</td><td>64 = patch volume (1×2×2) × 16 channels</td></tr><tr><td><code>unpatchify</code></td><td>16 × 21 × 90 × 160</td><td>Folded back into the latent shape</td></tr></tbody></table>
<p>Whatever shape goes in is the shape that comes out; the middle consists of 40 equal-width refinement layers. The semantics of the output are the <strong>velocity</strong> from Chapter 1, corresponding point-by-point to the input latent: the value at each position indicates “which direction the content at this position should move.” The text context (512 × 4096 projected by <code>text_embedding</code> to 512 × 5120) exists throughout only as the key/value for cross-attention and does not occupy sequence positions.</p>
<p>At this point, the machine that “takes a noisy latent and a <code>t</code>, then outputs a velocity prediction” is complete. The next chapter looks at how this prediction is used by the sampling loop.</p>
<h2>Chapter 5 Sampling Loop: 40 Steps, Two Forward Passes per Step</h2>
<p><em>The machine only reports a direction each time; what really walks the video out of noise is the loop around it: choose 40 stops, and ask for directions twice at each stop.</em></p>
<h3>UniPC: How to Move Fast and Steadily</h3>
<p>Once we have the velocity field, we still need a numerical integrator: at each step, take the velocity reported by the model and decide how far to move forward. This component is called the scheduler in code, and <code>fm_solvers_unipc.py</code> is exactly that. The simplest integration is Euler’s method: assume the velocity is constant at each step, <code>x ← x - Δσ·v</code>. It is first-order accurate. But the real velocity field is curved, so the larger the step, the farther it drifts. Wan2.2’s default <strong>UniPC</strong> is a multistep predictor-corrector: the predictor uses the model outputs from the most recent two steps, configured as <code>solver_order=2</code>, to fit the trend of velocity change and extrapolate to the next step; after the model output at the new position is computed, the corrector goes back and revises the step just taken, gaining another order of accuracy for free without spending an extra forward pass. With the same 40 model calls, the local error drops from Euler’s first-order small term to third order. This is how the step budget for “acceptable quality” gets cut from hundreds to dozens—video generation needs two 14B forward passes per step, so every saved step is real money.</p>
<h3>shift: Where the 40 Stops Land</h3>
<p>The scheduler is also responsible for deciding where the 40 sampling points are placed. <code>set_timesteps</code> first lays points out uniformly, then applies the shift transform:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">sigmas = shift * sigmas / (1 + (shift - 1) * sigmas)
</code></pre></figure>
<p>This transform squeezes the points toward the high-noise end, where σ is large. How much does it squeeze? Look at the median:</p>





















<table><thead><tr><th>shift</th><th>median timestep</th></tr></thead><tbody><tr><td>1 (no squeeze)</td><td>512</td></tr><tr><td>5</td><td>840</td></tr><tr><td>12 (t2v default)</td><td>926</td></tr></tbody></table>
<p>When <code>shift=12</code>, half of the sampling points fall in the <code>t>926</code> interval. The actual timestep sequence starts like this, with <code>shift=12</code> and 40 steps; you can verify it yourself by running <code>set_timesteps</code>, with the output rounded to integers:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">999, 997, 995, 993, 990, 988, ...
</code></pre></figure>
<p>The first few steps are spaced only about 2 apart, so the loop walks extremely densely; by the low-noise stage, the spacing stretches to dozens, and it crosses the stage in just a few steps.</p>
<p>Why allocate it this way? The high-noise stage determines composition, objects, and motion direction—the “from nothing to something” phase. The low-noise stage mostly polishes details. Tilting the budget toward the harder phase is a tradeoff video generation needs more than image generation does: image models usually use a shift of around 3, but motion consistency in video is much harder, so Wan2.2 raises it directly to 12. In the same repository, i2v uses 5.0 because the first frame is already given and the structural pressure is lower; ti2v-5B uses 5.0; s2v uses 3. Each task’s shift tells its own difficulty distribution. <code>--sample_shift</code> does not change the number of steps, only how those steps are allocated. Tuning it means moving budget between “more stable structure” and “finer details”—and it also meshes tightly with the expert split in the next chapter. We will do the numerical accounting there.</p>
<h3>CFG: Why Each Step Runs Two Forward Passes</h3>
<p><img src="https://img.leeguoo.com/media/5b6d9ab6-3eff-4b80-9a95-4edc0afd168c/blog-cfg-painters.png?v=3" alt="Two painters: one holding the positive prompt, one holding the negative checklist; the difference between the two paintings is amplified 3.5× into the final image" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>The core of the sampling loop, in <code>text2video.py</code>:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">noise_pred_cond = model(latent_model_input, t=timestep, **arg_c)[0]
noise_pred_uncond = model(latent_model_input, t=timestep, **arg_null)[0]

noise_pred = noise_pred_uncond + sample_guide_scale * (
    noise_pred_cond - noise_pred_uncond)
</code></pre></figure>
<p>For the same latent and the same <code>t</code>, it runs twice: once with your prompt, where <code>arg_c</code> contains <code>context</code>, and once with the negative prompt, where <code>arg_null</code> contains <code>context_null</code>. This is <strong>classifier-free guidance (CFG)</strong>.</p>
<p>The principle deserves one layer of math. To generate samples that satisfy condition <code>c</code>, decompose it with Bayes’ rule:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">p(x|c) ∝ p(x) · p(c|x)
</code></pre></figure>
<p>Take the log and then the gradient, the score:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">∇log p(x|c) = ∇log p(x) + ∇log p(c|x)
</code></pre></figure>
<p>The first term is the unconditional direction, what the data itself looks like. The second term is the classifier gradient that “makes x look more like it belongs to condition c.” Early methods really trained a separate classifier to provide it, which was expensive and hard to train. CFG’s insight is that this term can be obtained for free: the model has learned both conditional and unconditional predictions, by randomly dropping text during training, so subtracting the two gives:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">∇log p(c|x) = ∇log p(x|c) - ∇log p(x) ≈ conditional prediction - unconditional prediction
</code></pre></figure>
<p>The classifier gradient is hidden in the difference between the two forward passes. During sampling, amplify this term by <code>w</code>:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">final direction = uncond + w · (cond - uncond)
</code></pre></figure>
<p>Compare this with the three lines of code above: it is exactly the same. <code>sample_guide_scale</code> is <code>w</code>.</p>
<p>Choosing <code>w</code> is a pure tradeoff. <code>w=1</code> degenerates into conditional generation: the image looks natural, but often “doesn’t understand” the prompt. Raise <code>w</code>, and the model is strongly pushed toward regions where the “classifier” is more confident; composition obedience improves, at the cost of oversaturated colors, mushy details, and stiff motion, because the latent is pushed away from the high-density region of the training distribution. Image models often use around 7. Video is more sensitive to temporal naturalness, so Wan2.2’s A14B dual-expert series only uses 3.0–4.0; ti2v-5B uses 5.0, and s2v uses 4.5.</p>
<p>The role of the negative prompt in this derivation is also clear: standard CFG uses empty text for <code>uncond</code>; Wan2.2 replaces it with that string describing “overexposure, deformation, three legs,” and so on. The difference direction changes from “move closer to your prompt” into “move closer to your prompt while moving away from these defects.” One forward pass does two jobs at once.</p>
<p>The cost is just as clear: 40 steps × 2 forward passes = <strong>80 full inferences</strong> through the 14B model. Video generation is slow, and half the bill is charged to CFG.</p>
<h2>Chapter 6: Two 14B Experts Taking Turns</h2>
<p><em>The denoising loop and CFG in the previous chapter assume that one model is running behind the scenes. In fact, the A14B series hides two. This chapter explains how they divide the work, and why that directly determines whether your GPU has enough memory.</em></p>
<p><img src="https://img.leeguoo.com/media/33e3bbc5-c6a3-400d-8f7f-34c085fe694c/blog-moe-relay.png?v=3" alt="Two sculptors taking turns: the high-noise expert roughs out the material with a sledgehammer for steps 1–26, hands off at t=875 to the low-noise expert doing fine carving, with a bunk bed in the corner labeled both live in VRAM" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>The signature design of the Wan2.2 A14B series is that two 14B experts handle different noise ranges. In the config:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">t2v_A14B.boundary = 0.875
t2v_A14B.sample_guide_scale = (3.0, 4.0)  # low noise, high noise
</code></pre></figure>
<p>At each step, the sampling loop checks <code>t >= 0.875 × 1000</code>: if true, it uses <code>high_noise_model</code>; otherwise it uses <code>low_noise_model</code>. The motivation follows from the observation in Chapter 5: the high-noise stage determines structure out of chaos, while the low-noise stage polishes textures on a semi-finished result. Instead of forcing one network to master both jobs, it is better to let two networks each specialize in one. And this division already holds from the very first layer: the high-noise expert receives something close to pure Gaussian noise, so its early layers are “guessing structure from statistics”; the low-noise expert receives a semi-finished image, so its early layers are “reading the existing picture.” The input distributions are so different that sharing the lower layers would only drag both sides down. So the two experts are fully independent, trained separately on their own noise ranges, and take turns at inference time according to <code>t</code>.</p>
<p>This is what the official “27B total parameters, 14B active” means: 27B is the precise count, while the 14.05B from Chapter 4 only counted the major parts of each layer. Capacity doubles, but only one expert runs at any given step, so the compute cost is equivalent to a dense 14B model. Don’t be misled by the word “MoE”: LLM MoEs like Mixtral choose experts per layer and per token using a learned router. Here, routing happens at the timestep level, the threshold is hard-coded, there is no router at all, and the whole implementation is just an <code>if</code>. This coarse granularity brings two benefits: diffusion timesteps are already natural difficulty labels, so there is no need to train a router; and the two experts can be trained and upgraded independently.</p>
<p>Here is the full sampling loop. All the parts discussed in previous chapters come together in these 20 lines:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">for _, t in enumerate(tqdm(timesteps)):
    latent_model_input = latents
    timestep = [t]

    timestep = torch.stack(timestep)

    model = self._prepare_model_for_timestep(
        t, boundary, offload_model)
    sample_guide_scale = guide_scale[1] if t.item(
    ) >= boundary else guide_scale[0]

    noise_pred_cond = model(
        latent_model_input, t=timestep, **arg_c)[0]
    noise_pred_uncond = model(
        latent_model_input, t=timestep, **arg_null)[0]

    noise_pred = noise_pred_uncond + sample_guide_scale * (
        noise_pred_cond - noise_pred_uncond)

    temp_x0 = sample_scheduler.step(
        noise_pred.unsqueeze(0), t,
        latents[0].unsqueeze(0),
        return_dict=False, generator=seed_g)[0]
    latents = [temp_x0.squeeze(0)]
</code></pre></figure>
<p>Each round does five things: choose the expert, choose the guide scale, run two forward passes, combine them with CFG, and let the scheduler take one step. <code>_prepare_model_for_timestep</code> also contains the single-GPU lifesaving offload logic:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">if offload_model or self.init_on_cpu:
    if next(getattr(self, offload_model_name).parameters()).device.type == 'cuda':
        getattr(self, offload_model_name).to('cpu')
    if next(getattr(self, required_model_name).parameters()).device.type == 'cpu':
        getattr(self, required_model_name).to(self.device)
</code></pre></figure>
<p>With <code>--offload_model True</code> enabled, each time the expert switches, the off-duty one is moved back to system memory and the on-duty one is moved onto the GPU. Moving 28GB of weights across PCIe takes several seconds, but fortunately the whole sampling process switches only once: <code>t</code> decreases monotonically, and once it crosses the boundary, it never goes back. So this cost is paid only once.</p>
<h3>How <code>boundary</code> and <code>shift</code> Interact</h3>
<p>This is the easiest piece of accounting to miss when tuning. <code>boundary=0.875</code> looks, literally, like “only 12.5% of timesteps belong to the high-noise expert.” But as Chapter 5 explained, <code>shift</code> aggressively squeezes the sampling points toward the high-noise end. Counting the 40 steps where <code>t ≥ 875</code> gives:</p>





















<table><thead><tr><th>shift</th><th>High-noise expert steps (<code>t ≥ 875</code>)</th></tr></thead><tbody><tr><td>1</td><td>5 / 40</td></tr><tr><td>5</td><td>17 / 40</td></tr><tr><td>12 (<code>t2v</code> default)</td><td><strong>26 / 40, or 65%</strong></td></tr></tbody></table>
<p><img src="https://img.leeguoo.com/media/aa417179-78a2-4af9-99e7-019a90533fe7/blog-shift-squeeze.png?v=3" alt="Two 0–1000 number lines: 40 points under shift=1 are evenly distributed, while shift=12 squeezes them toward the 1000 end, with 26 steps circled to the right of 875" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>Most of the budget goes to the high-noise expert that determines structure, while the low-noise expert uses the remaining 14 steps to finish things off. If you tune <code>--sample_shift</code> alone without changing <code>boundary</code>, the workload split between the two experts changes silently, and the resulting quality fluctuations can be hard to reason about. The two <code>guide_scale</code> values also serve this division of labor: the high-noise stage uses 4.0 to stay close to the prompt during structure formation, while the low-noise stage drops to 3.0 so the detail stage is pushed less aggressively and the image looks more natural.</p>
<p>Finally, let’s clear up a common misunderstanding: only one expert appears at each timestep. The two forward passes are the CFG <code>cond</code> and <code>uncond</code> passes; the experts never work simultaneously in the same step. “Two forward passes per step” and “two experts” are orthogonal things.</p>
<h3>VRAM Accounting and the Three-Piece Kit</h3>
<p>Run the numbers again: two experts in bf16 weights take 56GB, the T5 encoder takes 11.4GB, plus the VAE, activations, KV cache, and other overhead. An 80GB card can just barely fit everything. When a single card cannot handle it, the three-piece kit targets specific items on the bill: <code>--offload_model True</code> moves the currently unused expert back to system memory, removing 28GB of resident VRAM; <code>--t5_cpu</code> keeps T5 in system memory and only moves it onto the GPU during encoding, saving 11.4GB; <code>--convert_model_dtype</code> lowers weight precision.</p>
<p>How should you combine them? The README’s example commands give the official answer: the single-GPU A14B example already includes <code>--offload_model True --convert_model_dtype</code> and notes that at least 80GB of VRAM is required. For running <code>ti2v-5B</code> on a 4090, all three are added, including <code>--t5_cpu</code>; in the 5B scenario, T5’s 11.4GB becomes proportionally the biggest item. The README’s <code>ti2v-5B</code> section also gives the reverse hint: if VRAM exceeds 80GB, remove these flags for a clear speedup, because offloading is not free.</p>
<h2>Chapter 7 Conditional Injection: Which Lines Changed for Image-to-Video</h2>
<p><em>By this point, you’ve already walked through the complete text-to-video pipeline. The remaining four tasks—image-to-video, text-image unified generation, audio, and animation—are not four brand-new model families. They are different “prompts” swapped onto the same backbone.</em></p>
<p>There are only three ways to add conditions:</p>
<ol>
<li><strong>cross-attention</strong>: the path used by text, where the condition is attended to as key/value</li>
<li><strong>channel concatenation</strong>: the condition tensor is directly concatenated with the noise latent along the channel dimension, then fed into patch embedding together</li>
<li><strong>AdaLN</strong>: the condition is mixed into the timestep embedding to modulate each layer’s norm</li>
</ol>
<p>Image-to-video (<code>i2v-A14B</code>) uses the second method. Look at how <code>image2video.py</code> constructs the condition:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">msk = torch.ones(1, F, lat_h, lat_w, device=self.device)
msk[:, 1:] = 0
msk = torch.concat([
    torch.repeat_interleave(msk[:, 0:1], repeats=4, dim=1), msk[:, 1:]
], dim=1)
msk = msk.view(1, msk.shape[1] // 4, 4, lat_h, lat_w)
msk = msk.transpose(1, 2)[0]
...
y = torch.concat([msk, y])
</code></pre></figure>
<p>Line by line: <code>msk</code> is the marker for “which frames are known.” The first frame—the input image—is marked as 1, and the remaining 80 frames are marked as 0. The following <code>repeat_interleave</code> plus reshape sequence does one thing: it folds the 81-frame pixel-level mask onto the 21-frame latent timeline. Remember the VAE compression rule from Chapter 2? The first frame gets its own latent frame, and every 4 frames after that are merged into one. The mask has to be folded by the same rule: copy the first frame 4 times to complete the grouping, then fold every 4 frames into one group, yielding a <code>4×21×h×w</code> mask aligned with the latent.</p>
<p><code>y</code> is the input image itself after passing through the VAE encoder, with the missing frames padded by zeros. The mask and <code>y</code> are concatenated into 20 channels, then concatenated once more with the 16-channel noise latent along the channel dimension before entering the model. At the start of <code>forward</code> in <code>model.py</code>:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">if y is not None:
    x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)]
</code></pre></figure>
<p>The i2v version of <code>WanModel</code> has 36 input channels: 16 noise channels + 16 image latent channels + 4 mask channels. The first convolution in patch embedding is wider than in the t2v version, while the remaining 40 layers have exactly the same structure. The entire backbone change for image-to-video is simply that the input channels go from 16 to 36.</p>
<p>What the model learns from these 36 channels is: copy the given content where <code>mask=1</code>, generate where <code>mask=0</code>, and keep the two spatially and temporally coherent. The first frame pins down the subject and style of the image, while the next 80 frames unfold motion around it. That is the mechanism behind “making an image move.”</p>
<p><img src="https://img.leeguoo.com/media/c2e92926-d9e9-4221-bfd1-5bd2722f248c/blog-i2v-mask.png?v=3" alt="81-frame film strip: the 1st frame is pinned to the photo with mask=1, the rest are empty with mask=0, folded into a 21-frame latent plus mask layer, then concatenated into 36 channels and fed into DiT" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<h3>Where the Conditions Come From in Other Tasks</h3>
<p>Read the other three tasks with the same mindset. Focus on: “What is the condition, and through which entrance does it enter the model?”</p>
<p><strong>ti2v-5B (unified text-image generation)</strong>. The same 5B backbone is paired with a high-compression <code>4×16×16</code> VAE, supporting both t2v and i2v without changing the architecture. If an image is provided, it is encoded, pinned into the first latent-frame position, and marked as known with a mask. If no image is provided, generation starts from pure noise. One model, two faces.</p>
<p><strong>s2v-14B (audio-driven generation)</strong>. The condition is audio: wav2vec2 turns the waveform into frame-level features, which are re-bucketed and aligned to the video frame rate before being injected into the model, driving lip motion and body rhythm. The video length follows the audio length. Extra-long audio is split into multiple segments and generated autoregressively, with each segment using the last few latent frames from the previous segment as a “momentum condition” for continuity. This is the only long-video solution in the repository.</p>
<p><strong>animate-14B (character animation/replacement)</strong>. This is the most condition-heavy task: a skeletal pose sequence drives motion, a facial feature sequence drives expressions, the reference image goes through CLIP visual encoding and enters cross-attention to lock identity, and in replacement mode there is also a background video and mask. Multiple condition streams enter through their own separate openings.</p>
<p>The condition sources vary wildly, but the injection methods are always permutations of the same three ideas: channel concatenation for spatially aligned conditions such as masks, poses, and backgrounds; cross-attention for global semantic conditions such as text and reference-image features; and frame-level feature injection for temporally aligned conditions such as audio. Once you understand one task, the rest are just combinations.</p>
<h2>Chapter 8: What Does 75600² Actually Mean?</h2>
<p><em>We have finished explaining how the model works and how conditions are injected. What remains is a pure engineering problem: with this much computation, what do you do when a single GPU cannot handle it? This chapter returns to the 75600 from Chapter 4 and looks at how it forces a multi-GPU solution.</em></p>
<p>Let’s calculate the attention bill in detail. The core computation of a single self-attention layer is two matrix multiplications, QK^T and AV. The FLOPs are approximately:</p>
<pre><code>2 × 2 × L² × dim = 4 × 75600² × 5120 ≈ 1.17 × 10¹⁴
</code></pre>
<p>(The two 2s mean: one for the two matrix multiplications, and one because each multiply-add pair counts as two FLOPs.) One layer is 117 TFLOPs. Across 40 layers and 80 forward passes, attention alone reaches the order of 3.7 × 10¹⁷ FLOPs. An A100 has a bf16 peak throughput of about 312 TFLOPS, so even under ideal utilization this part alone would take around twenty minutes, without counting the FFN. This is the physical baseline behind “a 5-second video takes minutes to tens of minutes to generate.”</p>
<p>If one GPU cannot handle it, split it. Wan2.2’s main solution is <strong>Ulysses sequence parallelism</strong>. The core communication primitive lives in <code>distributed/util.py</code>: a dimension exchange.</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">def all_to_all(x, scatter_dim, gather_dim, group=None, **kwargs):
    world_size = get_world_size()
    if world_size > 1:
        inputs = [u.contiguous() for u in x.chunk(world_size, dim=scatter_dim)]
        outputs = [torch.empty_like(u) for u in inputs]
        dist.all_to_all(outputs, inputs, group=group, **kwargs)
        x = torch.cat(outputs, dim=gather_dim).contiguous()
    return x
</code></pre></figure>
<p>Attention requires every token to see the full sequence, so you cannot simply split the sequence and call it done. Ulysses’ answer is: “split heads, not the sequence.” On 8 GPUs, tracing the tensor shapes through one pass gives the following (B=1, L=75600, 40 heads):</p>

























<table><thead><tr><th>Stage</th><th>Held by each GPU</th></tr></thead><tbody><tr><td>Before entering the block, sequence is split</td><td>L=9450, 40 heads</td></tr><tr><td><code>all_to_all</code> scatter head dimension, gather sequence dimension</td><td>L=75600, 5 heads</td></tr><tr><td>Local flash attention</td><td>Full sequence, 1/8 of heads</td></tr><tr><td><code>all_to_all</code> back</td><td>L=9450, 40 heads</td></tr></tbody></table>
<p>Two rounds of communication reduce both attention computation and activation memory per GPU to 1/8, because each GPU only computes 1/8 of the attention heads. The number of heads must be divisible by the number of GPUs; 40 heads with 8 GPUs fits perfectly. <code>generate.py</code> contains the corresponding assert.</p>
<p><img src="https://img.leeguoo.com/media/a8ea4e98-e3cc-49b3-b0be-72f4dd6c4495/blog-ulysses-graders.png?v=3" alt="8 graders each receive a full 75600-token exam, each grades only the heads assigned to them, with two rounds of all_to_all paper passing" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>There is also a sequence-length detail prepared specifically for multi-GPU execution. In <code>text2video.py</code>, the tail of the <code>seq_len</code> formula includes a pair of <code>ceil(... / sp_size) * sp_size</code>, rounding the token count up to an integer multiple of the GPU count. 75600 happens to be divisible by 8, so it remains unchanged. If another resolution produces 75601 tokens, it is padded to 75608, ensuring that each GPU receives an equal-length slice. On a single GPU, <code>sp_size=1</code>, so this expression is an identity; it only reveals its purpose in the multi-GPU setting.</p>
<p>The communication cost can also be calculated: per layer, q/k/v/output require four <code>all_to_all</code> calls, totaling about 3.1 GB of data. Across 40 layers and 80 forward passes, that accumulates to about 10 TB. That sounds scary, but NVLink inter-GPU bandwidth is on the order of hundreds of GB per second, so spread across the whole generation process, communication time is much smaller than compute time. The trade is worthwhile. On PCIe machines, however, this may not hold; multi-GPU speedup will shrink noticeably. As a side note, the competing approach Ring Attention splits the sequence and circulates K/V around the ring. It is not constrained by divisibility of head count, but its communication is more complex. Ulysses is capped by the number of heads: the GPU count cannot exceed 40 heads, so a 64-GPU cluster needs another scheme.</p>
<p>Parameters not fitting is another axis: <strong>FSDP</strong> (fully sharded data parallel) splits model weights into N shards and places them across GPUs. During the forward pass, when execution reaches a given layer, all GPUs first all-gather that layer’s parameters, compute the layer, then immediately release them. With 8 GPUs, the resident DiT weights per GPU drop from 56 GB to 7 GB. The cost is one all-gather per layer, but on NVLink most of this overhead can be overlapped with computation.</p>
<p>Putting the single-GPU and 8-GPU memory accounts side by side:</p>






























<table><thead><tr><th>Item</th><th>Single GPU without offload</th><th>8 GPUs fsdp + ulysses</th></tr></thead><tbody><tr><td>Two DiT expert weights bf16</td><td>56 GB</td><td>7 GB / GPU</td></tr><tr><td>T5 encoder weights</td><td>11.4 GB</td><td>Sharded or close to 0 after <code>--t5_cpu</code></td></tr><tr><td>Attention activations</td><td>Full sequence</td><td>1/8 sequence</td></tr><tr><td>Conclusion</td><td>80GB GPU is near the edge</td><td>Midrange GPUs are enough</td></tr></tbody></table>
<p>Sequence parallelism handles compute; FSDP handles memory. They are orthogonal, so <code>--dit_fsdp --ulysses_size 8</code> can be enabled at the same time. The 8-GPU command in the README includes both of them for a reason: every flag corresponds to a line in the accounting.</p>
<h2>Chapter 9: Why the Model Makes the Mistakes It Often Makes</h2>
<p>Most typical video-generation failures can be traced back to the principles discussed earlier. This chapter connects the “symptoms” to the “mechanisms,” which is more useful than memorizing a checklist.</p>
<p><strong>Broken fingers, text, and fine structures</strong>. This is the result of two levels of compression working together. The VAE compresses an 8×8 pixel block into one latent position, and patchify then merges 2×2 latents into one token, so one token governs 16×16 pixels. A finger is often only a dozen or so pixels wide; an entire finger may fall into one or two tokens. No matter how strong attention is, it cannot distinguish the boundaries of five fingers inside a token. Text is even worse, because strokes are pixel-level details. The only remedies are lowering the compression ratio or increasing resolution, both of which run directly into the compute wall. This is a structural weakness of the current generation of architectures, not the fault of the training data.</p>
<p><img src="https://img.leeguoo.com/media/e4c78b08-dd40-424e-98aa-d16841774564/blog-finger-tokens.png?v=3" alt="A hand overlaid with a coarse grid, one cell = 16×16 pixels; an entire finger falls into one cell, and under a magnifying glass the model cannot see the details inside the cell" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p><strong>Drift in the later part of long videos</strong>. t2v generates 81 frames in one shot, with inter-frame consistency enforced by full 3D attention, so it works fine within 5 seconds. Longer videos (s2v’s segmented autoregression) rely on “the last few frames of the previous segment” to pass state forward. Each handoff is lossy: errors accumulate segment by segment, and a character’s face slowly changes, clothing colors gradually drift. This is isomorphic to drift in long-form LLM text—the original sin of autoregression.</p>
<p><strong>Small motion range and static camera work</strong>. Multiple factors push in the same direction: CFG pushes the latent toward the region where the “classifier is most confident,” and static compositions are always “safer” than big movements; calm camera shots make up the majority of the training data; words like “static” and “still” in the negative prompt are there to fight this tendency (the default negative prompt includes “a still, motionless image,” so the author was clearly annoyed by this problem). Lowering <code>guide_scale</code> and explicitly describing camera movement in the prompt both loosen the constraints on motion.</p>
<p><strong>Physical inconsistency: clipping, strange liquids, drifting shadows</strong>. The model learns “what the next frame commonly looks like in pixel statistics,” without any explicit physics engine. Physical correctness only emerges in scenes densely covered by the training data. In sparse-data areas—complex occlusion, fluids, mirror reflections—it breaks down. This is a known boundary of the purely data-driven path, and the world-model direction the industry is exploring targets exactly this layer.</p>
<p><strong>Large differences across runs with the same prompt</strong>. Generation starts from random noise. The prompt only constrains “which semantic region the endpoint lands in”; the specific appearance inside that region is determined by the seed. This is a feature, not a bug: fixing the seed is what makes controlled comparisons possible. The hands-on experiments at the end all require a fixed seed for exactly this reason.</p>
<h2>Chapter 10 Symptom-to-Knob: A Quick Tuning Cheat Sheet</h2>
<p>Now that the principles are clear, translate them into “what should I tweak when I’m unhappy with the output?” The rationale for each row appears in the earlier chapters:</p>























































<table><thead><tr><th>Symptom</th><th>Knob to Adjust</th><th>Source of Principle</th></tr></thead><tbody><tr><td>Chaotic composition, objects breaking down</td><td>Increase <code>--sample_shift</code>, or add steps with <code>--sample_steps</code></td><td>Chapter 5: structure is decided in the high-noise phase; shift tilts the budget toward that side</td></tr><tr><td>Image feels stiff, motion range is small</td><td>Lower <code>--sample_guide_scale</code></td><td>Chapter 5: when <code>w</code> is too large, it pushes the latent away from the natural distribution, and motion stiffens first</td></tr><tr><td>Does not follow the prompt at all</td><td>Raise <code>--sample_guide_scale</code>; make the prompt more specific, or enable <code>--use_prompt_extend</code></td><td>Chapter 5 CFG + Chapter 3 prompt expander</td></tr><tr><td>Colors are oversaturated, details are smeared</td><td>Lower <code>--sample_guide_scale</code></td><td>Chapter 5: a typical symptom of excessive <code>w</code></td></tr><tr><td>Details are blurry but the structure is correct</td><td>Lower <code>--sample_shift</code>, leaving more steps for the low-noise phase</td><td>Chapter 5: details are polished in the low-noise phase</td></tr><tr><td>Output is too slow</td><td>Reduce <code>--sample_steps</code> (trade quality for speed); for multi-GPU, increase <code>--ulysses_size</code></td><td>Chapter 5’s accounting for 80 forward passes + Chapter 8</td></tr><tr><td>OOM</td><td>Use the three-piece combo: <code>--offload_model True --convert_model_dtype --t5_cpu</code>, or switch to <code>ti2v-5B</code></td><td>Chapter 6 memory accounting + Chapter 2’s two VAE versions</td></tr><tr><td>Reproduce the same video</td><td>Fix <code>--base_seed</code></td><td>Noise is determined by the seed; same seed + same input = same output</td></tr><tr><td>Frame count reports a shape error</td><td>Change <code>--frame_num</code> to <code>4n+1</code></td><td>Chapter 2 VAE compression rule</td></tr></tbody></table>
<p>One more reminder beyond the table: adjusting <code>--sample_shift</code> silently changes how the workload is divided between the two experts; see the linked accounting in Chapter 6.</p>
<h2>Settle the Accounting</h2>
<p>Returning to the command at the beginning, every stage now has numbers attached:</p>
<ul>
<li>The prompt is turned by umT5 (5.7B) into ≤512 vectors of 4096 dimensions; the negative prompt is processed the same way</li>
<li>Random-noise latent: 16 × 21 × 90 × 160, determined by <code>--base_seed</code>; reproducible with the same seed</li>
<li>Patchified into 75,600 tokens of 5120 dimensions</li>
<li>40-step UniPC sampling; <code>shift=12</code> squeezes 26 steps into the high-noise region of t≥875, handled by the high-noise expert (14B), then the final 14 steps switch to the low-noise expert</li>
<li>2 forwards per step (CFG, <code>guide_scale</code> 4.0 for the high-noise stage and 3.0 for the low-noise stage), totaling 80 14B inference passes; attention compute is on the order of 10¹⁷ FLOPs</li>
<li>The latent is stream-decoded by the causal-convolution VAE back into 81 frames at 720p, then encoded to disk with libx264</li>
</ul>
<p>The bulk of the time is spent on the 80 middle 14B forward passes, taking up more than 90% of the whole run; T5 and the VAE each run only once. So every speedup technique targets the sampling loop: fewer steps, less CFG (distillation), splitting attention across more GPUs. Nobody bothers optimizing T5 or the VAE, because that is not where the bill is.</p>
<p>There is no magic in this system, only the combination of four things: a compressor (VAE), a large network that learns the velocity field in compressed space (DiT), numerical integration that moves quickly and stably (flow matching + UniPC + shift), and conditioning mechanisms that inject semantics (cross-attention + CFG + task-specific injections). The skeleton is the post-2024 industry common denominator. Read another video model—Kling, Sora, Veo—and the parts differ, but the way they are bolted together is similar. Each company’s real moat is its data recipe, which happens to be the part you cannot see in an open-source repository.</p>
<p>If you can take away only three sentences from the whole piece, take these:</p>
<ol>
<li><strong>Generation happens on thumbnails</strong>: the video is first shrunk 46× by the VAE; the large model touches only the compressed representation throughout, and only enlarges it back to pixels at the final step.</li>
<li><strong>Denoising is straight-line navigation that asks for directions 40 times</strong>: each time, the model reports a direction (and asks both the positive and negative sides, then takes the difference—that is CFG); the scheduler moves one step along that direction; the first 26 steps set the structure, the last 14 polish the details, and the two experts hand off at tick 875.</li>
<li><strong>Every engineering problem comes from one number</strong>: quadratic attention over 75,600 tokens. VRAM, speed, and multi-GPU schemes are all fighting against it.</li>
</ol>
<h2>What to Read Next, and What to Try Hands-On</h2>
<p>Read the papers along this path; each one maps to one or two chapters of this article:</p>








































<table><thead><tr><th>Paper</th><th>Corresponding Section</th><th>One-Sentence Summary</th></tr></thead><tbody><tr><td>DDPM (Ho et al., 2020)</td><td>First half of Chapter 1</td><td>The foundational framework of diffusion generation</td></tr><tr><td>Flow Matching (Lipman et al., 2022) / Rectified Flow (Liu et al., 2022)</td><td>Chapter 1</td><td>Straightening stochastic curves into lines</td></tr><tr><td>Latent Diffusion (Rombach et al., 2021)</td><td>Chapter 2</td><td>Why generation is done in compressed space</td></tr><tr><td>DiT (Peebles &#x26; Xie, 2022)</td><td>Chapter 4</td><td>Transformer diffusion backbones and AdaLN</td></tr><tr><td>Classifier-Free Guidance (Ho &#x26; Salimans, 2022)</td><td>Chapter 5</td><td>The difference between two forward passes is the classifier gradient</td></tr><tr><td>Wan technical report (arXiv:2503.20314)</td><td>Entire article</td><td>How all of this is assembled into a video model</td></tr></tbody></table>
<p>A hands-on path builds intuition faster than papers. I recommend this order:</p>
<ol>
<li>Run the demo with ti2v-5B on a consumer GPU, fixing <code>--base_seed</code>.</li>
<li>Change only <code>--sample_shift</code> (for example, run 3 / 5 / 12 once each). Compare them with the same seed and visually inspect how the budget shifts between “structure vs. detail.”</li>
<li>Change only <code>--sample_guide_scale</code> (1 / 3 / 7), and observe the trade-off between prompt adherence and oversaturation.</li>
<li>Set a breakpoint in the sampling loop of <code>text2video.py</code>; at each step, print <code>t.item()</code>, <code>latents[0].std().item()</code>, and the current expert. Compare this against the tables in Chapters 5 and 6, and manually reproduce the 26/14 step allocation.</li>
<li>Pick any three of the 16 latent channels and render them as RGB. Watch the “ghost” of the video gradually take shape—turning the slogan “diffusion carves structure in latent space, and the VAE fills in details” into intuition.</li>
<li>Read the mask construction in <code>image2video.py</code>, compare it with Chapter 7, then ask yourself: if the task were “generate the middle given the first and last frames,” how should the mask change? The industry calls this task FLF2V; Wan2.1 released this variant, and you can use its implementation to check your answer.</li>
</ol>
<p>On the engineering side, this codebase still has quite a few pitfalls where the implementation diverges from the documentation. The pitfall checklist is in another article: <a href="https://blog.leeguoo.com/en/posts/wan22-source-code-deep-dive/" rel="noopener" target="_blank">After Reading All of the Wan2.2 Source Code</a>. The theory and the gotchas are best read together.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/wan22-how-video-generation-works/</link>
    <guid>https://blog.leeguoo.com/en/posts/wan22-how-video-generation-works/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Fri, 17 Jul 2026 07:00:37 GMT</pubDate>
  </item>
  <item>
    <title>After Reading All of Wan2.2’s Source Code: Where This Video Generation Code Will Bite You</title>
    <description>From the five-task matrix and layered architecture to DiT/VAE/T5/distributed execution/schedulers, this article breaks down, layer by layer, the real pitfalls in the Wan2.2 source code where the documentation and implementation diverge.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>After reading through the Wan2.2 repository from <code>generate.py</code> all the way to the schedulers, what is really worth writing down is not “how to use this model,” but how this code can bite you when you are not paying attention.</p>
<p>Wan2.2 is an open-source video generation model library from Alibaba’s Wan team. It has a single <code>generate.py</code> entry point and uses <code>--task</code> to branch into five inference pipelines: text-to-video, image-to-video, unified text-image-to-video, speech-to-video, and character animation. On the surface, it looks like “one model, five ways to play.” Once you open the code, it feels more like five parallel worlds glued together by a thin CLI layer. Below, I’ll go through it in the order I read the code, marking the pitfalls where they appear and pasting the source code directly in the key places.</p>
<p>This article is about engineering pitfalls. If you want to first understand the principles behind video generation itself—VAE compression, flow matching, CFG, and the math and numbers behind the MoE dual experts—read the companion piece: <a href="https://blog.leeguoo.com/en/posts/wan22-how-video-generation-works/" rel="noopener" target="_blank">Learning Video Generation Through the Wan2.2 Source Code</a>. The two are best read together.</p>
<p><img src="https://img.leeguoo.com/media/2c77d3ee-9df8-4f63-899a-212ea30d79ff/wf-00-index.png?v=3" alt="Wan2.2 overview" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<h2>Five Tasks, Five Input Sets, Five Pitfall Sets</h2>
<p>Wan2.2 is a task matrix. <code>t2v-A14B</code>, <code>i2v-A14B</code>, <code>ti2v-5B</code>, <code>s2v-14B</code>, and <code>animate-14B</code> each require completely different assets: plain text, a reference image, a reference image plus audio, or even a video plus a pose asset package produced by preprocessing. Which task to use and what inputs to prepare—the answer lives in the argument validation code in <code>generate.py</code>; the docs only tell half the story.</p>
<p><img src="https://img.leeguoo.com/media/2e3f8a01-72db-421a-a1c7-1f99ed31954f/wf-01-business-understanding.png?v=3" alt="Business understanding" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>First, look at this default fallback logic in <code>generate.py</code>:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">if args.prompt is None:
    args.prompt = EXAMPLE_PROMPT[args.task]["prompt"]
if args.image is None and "image" in EXAMPLE_PROMPT[args.task]:
    args.image = EXAMPLE_PROMPT[args.task]["image"]
</code></pre></figure>
<p>If you don’t pass <code>--prompt</code>, the code doesn’t error out; it silently applies the hardcoded sample prompt: a white cat wearing sunglasses surfing. <code>s2v-14B</code> and <code>i2v-A14B</code> even share the same sample. Run these two tasks without passing a prompt, and the results will have the same style, because both tasks are just idling on the official demo assets. <code>--image</code>, <code>--audio</code>, and <code>--tts_*</code> all have similar fallbacks. After beginners get the demo running for the first time, they often mistakenly think their own configuration has taken effect. This is the number one source of confusion.</p>
<p>The meaning of <code>--size</code> does not line up across tasks. In <code>t2v</code>/<code>ti2v</code>, it means resolution; in <code>i2v</code>/<code>s2v</code>, it is effectively the target pixel area, and the final aspect ratio follows the input image. Filling in <code>1280*720</code> does not guarantee the output will be that size. <code>frame_num</code> must satisfy <code>4n+1</code>, and the default values differ across tasks (<code>t2v</code>/<code>i2v</code> use 81, <code>ti2v-5B</code> uses 121, and <code>animate-14B</code> uses 77). Behind this is the VAE temporal compression ratio, so this number cannot be changed casually. <code>Wan-Animate</code> is a two-stage workflow: first run the standalone <code>preprocess_data.py</code> to produce a pose/mask/reference asset package, then feed that directory to <code>generate.py</code>. Skipping preprocessing and passing the raw video path directly is the most common way this codebase blows up.</p>
<h2>The Five-Layer Architecture: Who Glues to Whom</h2>
<p>Break apart the <code>wan/</code> package: <code>configs</code> determines which weights and shapes the model runs with; <code>modules</code> is pure network definitions and has no concept of a “task”; <code>distributed</code> is a parallelism toolbox for pipelines; <code>utils</code> is sampling math and miscellaneous helpers. The pieces that glue them together are pipeline classes like <code>text2video.py</code> and <code>image2video.py</code>.</p>
<p><img src="https://img.leeguoo.com/media/e0a93afd-e50a-40a4-ab67-edd3fb237e54/wf-02-architecture-overview.png?v=3" alt="Architecture Overview" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>This layer carries a few bits of historical baggage. <code>wan/distributed/__init__.py</code> is an empty file; every pipeline bypasses it and imports directly from submodules, leaving the package name as nothing more than a namespace placeholder. <code>wan/utils/__init__.py</code> lists <code>HuggingfaceTokenizer</code> in its <code>__all__</code>, but that class is actually exported from <code>wan/modules/tokenizers.py</code>. If you follow <code>__all__</code> and write <code>from wan.utils import HuggingfaceTokenizer</code>, you get an <code>ImportError</code> straight away—an outdated declaration that was never cleaned up.</p>
<p>There is also a cost to the line <code>import wan</code>: it eagerly pulls in all five pipelines and their respective dependencies. Even if you only want to run <code>t2v-A14B</code>, it also drags in heavy TTS/CosyVoice dependencies from <code>speech2video.py</code>. If any task module fails to import, the four unrelated tasks go down with it.</p>
<h2>What a Single Generation Passes Through, and Where It Quietly Fails</h2>
<p>Using <code>t2v-A14B</code> (MoE dual-expert) as the main thread: <code>generate.py</code> parses arguments → selects a pipeline class → encodes text → runs diffusion sampling → decodes with the VAE → saves as an mp4.</p>
<p><img src="https://img.leeguoo.com/media/949e06aa-5c0b-48c2-b8c5-d8f67104a551/wf-03-core-flow.png?v=3" alt="Core flow" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>Argument validation is unevenly granular. If <code>i2v-A14B</code> is missing <code>--image</code>, it crashes directly on an <code>assert</code>; if <code>s2v-14B</code> is missing <code>--audio</code>, or <code>animate-14B</code> is missing <code>video</code>/<code>pose</code>/<code>mask</code>, there are no corresponding assertions, so the error is pushed out to runtime. The docs say <code>frame_num</code> must satisfy <code>4n+1</code>, but the code has no such <code>assert</code>; pass the wrong frame count and it will only explode later when internal DiT/VAE shapes no longer line up, with the error location far removed from the cause.</p>
<p>One easily misunderstood point in the main sampling loop: after CFG (running DiT once for the conditional path and once for the unconditional path) is combined with MoE dual-expert switching, each timestep means “choose one expert, run two forward passes.” The two experts never appear in the same step. The switching condition is <code>t.item() >= boundary * num_train_timesteps</code>, directly comparing against the timestep value emitted by the scheduler. These are two independently maintained pieces of state: change the scheduler, or change <code>shift</code> so the timestep value range drifts, and the expert switch point shifts with it. No error is raised; you can only notice it by visually observing quality degradation.</p>
<p>Now look at writing to disk. The tail of <code>save_video</code> in <code>wan/utils/utils.py</code>:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">        # write video
        writer = imageio.get_writer(
            cache_file, fps=fps, codec='libx264', quality=8)
        for frame in tensor.numpy():
            writer.append_data(frame)
        writer.close()
    except Exception as e:
        logging.info(f'save_video failed, error: {e}')
</code></pre></figure>
<p>Encoding failures only print a single <code>logging.info</code> line and do not raise. The main flow never learns that saving failed, continues on to <code>merge_video_audio</code>, and eventually logs <code>Finished</code>, while no mp4 exists on disk. If ffmpeg fails inside <code>merge_video_audio</code>, that is swallowed in the same way. The entire chain can be correct up to the end, die on the final step, and still stay silent.</p>
<h2>Five Parallel Worlds</h2>
<p>The five task classes do not share a base class. The common skeleton <code>_configure_model</code> is copied once into each of the five files: <code>eval</code> + freeze gradients → monkey-patch attention when using sequence parallelism → <code>dist.barrier()</code> → FSDP sharding or move to GPU.</p>
<p><img src="https://img.leeguoo.com/media/36276abd-0fb5-4450-9b42-3a8033d33d06/wf-04-task-classes.png?v=3" alt="Implementation comparison of the five major task classes" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>With five copies, the details have already drifted. The S2V and Animate versions have one extra line, <code>model.use_context_parallel = True</code>; the other three do not, making it hard to tell whether that is intentional or an omission. The default CFG switch is also inconsistent: four tasks enable it by default, while Animate disables it by default (<code>guide_scale=1</code> means CFG does not run). If you treat “Animate is faster” as a bug while comparing tuning results, you can waste a lot of time: it is an intentional compute-saving design. The docstring sentence “only used for expression control, unnecessary in most cases” explains why, but the code will not remind you to look.</p>
<p>TI2V is essentially a dispatcher: pass an image and it goes through the internal i2v branch; pass no image and it goes through t2v, using the same model. It has one default-value trap: the <code>WanTI2V.generate()</code> signature has <code>frame_num=81</code>, while the forwarding targets <code>t2v()</code> / <code>i2v()</code> have <code>frame_num=121</code> in their signatures. If you use this class directly as a Python library and do not explicitly pass a frame count, you get 81 frames. CLI users avoid this because <code>generate.py</code> always explicitly passes the configured value, 121, bypassing that default.</p>
<p>S2V is the only autoregressive segmented task. It computes <code>num_repeat</code> from the audio length, generates segments in a loop, and stitches them together using the latent at the end of the previous segment. Its <code>get_gen_size</code> leaves an interface for “continuing from the previous generated result” (the <code>pre_video_path</code> parameter), but the main flow always passes <code>None</code>. The interface exists, but the path is not connected, so it is easy to overestimate what it can do when reading the code.</p>
<h2>Graceful Degradation Was Written, but Nobody Uses It</h2>
<p>The DiT backbone (<code>WanModel</code>) is a stack of repeated Attention Blocks: Self-Attention with RoPE, Cross-Attention connected to text context, and FFN, driven by six-way AdaLN modulation.</p>
<p><img src="https://img.leeguoo.com/media/ae45f413-59bf-4df6-849e-ead34fa31aab/wf-05-core-modules.png?v=3" alt="Core model modules" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>There is a dispatch function with fallback in <code>wan/modules/attention.py</code>, <code>attention()</code>: if FlashAttention is installed, it forwards to it; if not, it falls back to PyTorch’s <code>scaled_dot_product_attention</code>. But line 9 of <code>model.py</code> imports it like this:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">from .attention import flash_attention
</code></pre></figure>
<p>The backbone directly calls the lowest-level <code>flash_attention()</code>, and when flash-attn is not installed, that function contains a hard <code>assert FLASH_ATTN_2_AVAILABLE</code> (<code>attention.py:112</code>) and crashes immediately. The fallback was written, but the backbone does not use it. The attention kernel also requires <code>q.device.type == 'cuda'</code>; on CPU and Mac MPS, the first call hits an assertion failure with no helpful message.</p>
<p>The VAE pitfall is hidden one layer deeper. The Wan2.1 version (16 channels) and the Wan2.2 version (48 channels, with added patchify and dual-path downsampling) are structurally incompatible, and their two sets of normalization <code>scale</code> dimensions differ. If the checkpoint and VAE version are mismatched, the error is just a tensor shape mismatch, making the root cause hard to see. Wan2.2 VAE’s <code>encode</code>/<code>decode</code> also use <code>try/except TypeError</code> to fall back on invalid input: pass the wrong type, and it swallows the exception, logs it, and returns <code>None</code>; the caller only blows up much later because of that <code>None</code>. The Wan2.1 version does not have this <code>try/except</code> layer. Same repository, two VAE versions, two different personalities for error tolerance.</p>
<p>On the T5 side, <code>umT5</code> uses per-layer independent relative positional encoding: all 24 layers compute their own bias, and the config has <code>shared_pos=False</code>. This is not a bug; it is umT5’s design, but if you do not look at that config line, it is easy to assume the bias is shared. The more practical risk is elsewhere: <code>t5.py</code> and both VAE versions load checkpoints using bare <code>torch.load</code>, without <code>weights_only=True</code>, which creates a risk of arbitrary code execution during deserialization when loading weight files from unknown sources.</p>
<h2>How VRAM and compute are split</h2>
<p>There are two axes for multi-GPU strategy: FSDP handles VRAM through parameter sharding, while Ulysses sequence parallelism handles compute by slicing the activation sequence dimension across GPUs. Both use the global <code>world</code> from <code>torch.distributed</code>; there is no separate data-parallel dimension. All GPUs in a single launch either belong to the same parallel group, or the run is single-GPU.</p>
<p><img src="https://img.leeguoo.com/media/4a2fb674-a7d3-4cb4-8f5c-883d937d4e08/wf-06-distributed-inference.png?v=3" alt="Multi-GPU distributed inference strategy" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>Ulysses works through two transformations: scatter q/k/v along the heads dimension and gather along the sequence dimension, so each GPU gets the full sequence and computes a subset of heads locally, runs FlashAttention locally, then swaps everything back with all-to-all. Hidden inside is an equal-length assumption: <code>sp_dit_forward</code> uses <code>torch.chunk</code> to split the sequence, and <code>pad_freqs</code> computes positional encodings based on “equal-length slices per GPU.” When <code>torch.chunk</code> does not divide evenly, the last chunk is shorter. If the sequence length is not an integer multiple of <code>ulysses_size</code>, the RoPE frequencies no longer line up with the tokens. The error is silent and only shows up as degraded quality.</p>
<p>FSDP’s VRAM savings also do not kick in from the start: the model is first moved onto the GPU in full, then passed to <code>shard_model</code> for sharding, with no meta-device initialization path. Before sharding, every GPU still has to fit the full model, so VRAM-constrained cards can OOM immediately at startup. In addition, <code>free_model</code> touches FSDP’s private <code>_handle.flat_param.data</code> attribute, which could break at any time after a PyTorch upgrade.</p>
<h2>The shift parameter takes effect twice, in two places</h2>
<p>The schedulers (<code>FlowUniPCMultistepScheduler</code> / <code>FlowDPMSolverMultistepScheduler</code>) are flow-matching versions adapted from the diffusers schedulers; the file header says "Copied from diffusers".</p>
<p><img src="https://img.leeguoo.com/media/38393f5b-ab8b-479c-90f2-20673cd79709/wf-07-schedulers-and-prompt.png?v=3" alt="Schedulers and prompt expansion" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>The UniPC construction code in <code>text2video.py</code>:</p>
<figure class="t-codeblock" data-lang="python"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">python</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-python">sample_scheduler = FlowUniPCMultistepScheduler(
    num_train_timesteps=self.num_train_timesteps,
    shift=1,
    use_dynamic_shifting=False)
sample_scheduler.set_timesteps(
    sampling_steps, device=self.device, shift=shift)
</code></pre></figure>
<p>The <code>shift=1</code> in the constructor is a hardcoded value; the real <code>--sample_shift</code> takes effect in <code>set_timesteps</code>. The DPM++ path is different again: before the call, it manually computes the sigma sequence with <code>get_sampling_sigmas(steps, shift)</code> and feeds that in. If you want to adjust the default shift value or add a third solver, copying one side makes it easy to miss the other; if you take the name at face value and change the constructor parameter, the change has no effect.</p>
<p>Prompt rewriting has two backends: the online DashScope API and local Qwen. It runs only once on rank 0 and then broadcasts the result, avoiding repeated multi-GPU calls. Local Qwen moves the entire model CPU↔GPU once per call, a tradeoff to free VRAM for the main model; the cost is obvious latency on every rewrite. The nested structure of the three tasks in the <code>DEFAULT_SYS_PROMPTS</code> dictionary is inconsistent, and <code>decide_system_prompt</code> relies on hardcoded string-matching branches. Adding a new task requires manually writing a new branch, and a wrongly written nesting only surfaces at runtime as a <code>KeyError</code>.</p>
<h2>Three Resolution Tables</h2>
<p>The biggest naming trap in the config layer: <code>t5_checkpoint</code>/<code>vae_checkpoint</code> are file paths, while <code>low_noise_checkpoint</code>/<code>high_noise_checkpoint</code> are <code>subfolder</code> names passed to <code>from_pretrained</code>. The same <code>xxx_checkpoint</code> naming convention has two different meanings. When you manually organize model directories or rename subfolders, the error comes from the transformers layer as “cannot find config.json”; it won’t tell you that the field was configured incorrectly.</p>
<p><img src="https://img.leeguoo.com/media/24e55e71-b72f-4f82-a657-cf0b0cdb0ad2/wf-08-configs-and-checkpoints.png?v=3" alt="Task configuration and model download" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>The resolution whitelist is split across three independently maintained tables: <code>SIZE_CONFIGS</code>, <code>MAX_AREA_CONFIGS</code>, and <code>SUPPORTED_SIZES</code>. <code>ti2v-5B</code> uses <code>704*1280</code>, while other tasks commonly use <code>720*1280</code>; the two numbers look so similar that README files and community posts often mix them up. If you pass the wrong one, the error is not “resolution unsupported,” but a missing key lookup across the three tables. Adding a new resolution means changing all three tables at once, and if you miss one, there is no static check to warn you.</p>
<h2>A Shared Pattern</h2>
<p>Put the nine analyses together, and a pattern emerges: most of the traps in this codebase are not logic errors, but safety fallbacks that were implemented and then not used by the main path. The fallback in <code>attention()</code>, the inconsistent assertions in <code>_validate_args</code>, the mismatch between the documented <code>frame_num</code> requirements and the code validation, and the two effective paths for <code>shift</code> are all variations of the same pattern. Documentation, defaults, and actual code paths are out of sync. The deeper you go into the pipeline, the more hidden the cost becomes: CLI errors degrade into runtime errors, then further degrade into no error at all while quality quietly gets worse.</p>
<p>One practical lesson when taking over this code for secondary development: trust the paths that actually take effect in the code, not the literal meaning of docstrings or field names. When the same field or function has different semantics across tasks, verify it separately; do not assume it is shared across tasks.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/wan22-source-code-deep-dive/</link>
    <guid>https://blog.leeguoo.com/en/posts/wan22-source-code-deep-dive/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 16 Jul 2026 17:50:02 GMT</pubDate>
  </item>
  <item>
    <title>wechat-decrypt vs wechat-use: How Each Gets the WeChat Database Key on macOS</title>
    <description>wechat-decrypt (ylytdeng) and wechat-use both read WeChat 4.x’s SQLCipher databases; the real difference is how they get the key. wechat-decrypt re-signs WeChat and scans its memory with sudo + mach_vm; wechat-use grabs the key through the debug interface at login, with no re-sign and no sudo. Tested on my own Mac, including two first-draft claims I had to correct.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p><img src="https://img.leeguoo.com/media/a948ae6a-7230-4ff0-810c-34e515d4fac4/cover.png?v=3" alt="Snoopy holding up a key toward a locked chat bubble" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<p>WeChat 4.x locks the local chat database inside SQLCipher. If you want a program to read chat history, images, and voice messages, there is one thing you cannot avoid: first obtaining the decryption key.</p>
<p>Around the task of “getting the key, then reading the database,” the community has developed two mature approaches. One is <code>wechat-use</code>—the macOS toolkit for sending messages, reading databases, and real-time monitoring. The other is the open-source <code>wechat-decrypt</code>—for cross-platform decryption, batch export, speech-to-text, and native MCP support.</p>
<p>The two are often compared, but usually in the wrong direction. The real difference is not “which one has more features,” but <strong>how they obtain that key</strong> and <strong>what they can do after obtaining it</strong>. This article breaks both down at the principle level. After writing it, I actually ran <code>wechat-use</code> on my own machine—and ended up correcting two mistaken judgments I made earlier, which I’ll explain later.</p>
<h2>First, Let’s Make the Encryption Model Clear</h2>
<p>WeChat 4.x’s local databases are standard <strong>SQLCipher 4</strong>-encrypted SQLite files, stored at:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">macOS: ~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/&#x3C;wxid>/db_storage
</code></pre></figure>
<p><img src="https://img.leeguoo.com/media/2ba94d51-7db5-42e6-8cb8-ab17d2ff1109/sqlcipher-model-snoopy.png?v=3" alt="SQLCipher decryption model: file-header salt + in-memory raw key → SQLCipher 4 → plaintext" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>There are three things you must understand first about SQLCipher’s key model. Otherwise, the rest of the article—what exactly the two tools are digging for—will not make sense:</p>
<ul>
<li><strong>One database, one key.</strong> There is no single global key. <code>contact.db</code>, <code>message_*.db</code>, <code>sns.db</code>… each database has its own 32-byte raw key. On my own machine, the <code>~/.wx-rs/keys.json</code> saved by <code>wechat-use</code> contains exactly <strong>19 per-DB entries</strong>, each in the form <code>{key_hex: 64 chars, salt_hex: 32 chars}</code>.</li>
<li><strong>The salt is hidden in the file header.</strong> The <strong>first 16 bytes of each encrypted DB file are a random salt</strong>. This is also why an encrypted SQLite file does not start with <code>SQLite format 3</code> when opened—the first 16 bytes have been occupied by the salt. In <code>wechat-decrypt</code>’s C code, there is this line: <code>memcmp(header, "SQLite format 3", 15) == 0 → return -1</code>; if it hits a plaintext database, it skips it.</li>
<li><strong>The raw key only lives in memory.</strong> The 32-byte raw key is not written to disk. WeChat derives it only at runtime and holds it inside the process memory. Its format is <code>x'&#x3C;64-char key hex>&#x3C;32-char salt hex>'</code>, which is the raw form of SQLCipher’s <code>PRAGMA key</code>: 96 hexadecimal characters.</li>
</ul>
<p>So to decrypt a database, you need two things: <strong>the raw key, dug out of process memory + the salt, read from the first 16 bytes of the DB file header</strong>. The constants in <code>wechat-decrypt</code>’s <code>find_all_keys_macos.c</code>—<code>HEX_PATTERN_LEN 96</code>, <code>key_hex[65]</code>, and <code>salt_hex[33]</code>—map exactly to this model.</p>
<p><strong>“The salt is in the file header” is not something I made up; a single hexdump verifies it.</strong> The first 16 bytes of my local <code>contact.db</code> are:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">$ xxd -l 16 contact/contact.db
00000000: 90f7 c71d 17b5 bfe6 24b1 76ac df40 df7f
</code></pre></figure>
<p>A plaintext SQLite file should start with <code>53 51 4c 69 74 65 …</code> (<code>"SQLite format 3\000"</code>). Here, it is just a blob of random bytes—<strong>these 16 bytes are the salt</strong>, and encryption has overwritten the original file header. <code>wechat-decrypt</code> uses exactly this check, <code>memcmp(header, "SQLite format 3", 15)</code>, to determine whether a database is already plaintext.</p>
<p><strong>One account has a whole set of databases, not just one file.</strong> WeChat splits data by purpose into nearly 20 encrypted databases, each with its own key. The 19 entries in my local <code>~/.wx-rs/keys.json</code> correspond to:</p>





















































<table><thead><tr><th>Database</th><th>What It Stores</th></tr></thead><tbody><tr><td><code>message/message_0.db</code>, <code>message_1.db</code></td><td>Chat message bodies, sharded by volume</td></tr><tr><td><code>message/message_fts.db</code></td><td>Full-text search index for chats</td></tr><tr><td><code>message/media_0.db</code>, <code>message_resource.db</code></td><td>Media/resources inside messages</td></tr><tr><td><code>message/biz_message_0.db</code></td><td>Official account/service account messages</td></tr><tr><td><code>session/session.db</code></td><td>Conversation list, the left sidebar</td></tr><tr><td><code>contact/contact.db</code>, <code>contact_fts.db</code></td><td>Contacts + contact search</td></tr><tr><td><code>sns/sns.db</code></td><td>Moments</td></tr><tr><td><code>favorite/favorite.db</code>, <code>favorite_fts.db</code></td><td>Favorites</td></tr><tr><td><code>emoticon/emoticon.db</code> / <code>head_image/head_image.db</code></td><td>Stickers / avatars</td></tr><tr><td><code>hardlink/hardlink.db</code></td><td>File hardlink index</td></tr><tr><td><code>bizchat</code>, <code>general</code>, <code>solitaire</code>, <code>weclaw</code></td><td>Enterprise account chats / general config / solitaire / etc.</td></tr></tbody></table>
<p><strong>The hard part has never been decryption—SQLCipher’s standard library can open the database as long as you give it the right key. The hard part is digging that key out of memory.</strong> This step is where the two tools truly diverge.</p>
<h2>Core Principle: Two Ways to Dig the Key Out of Memory</h2>
<p><img src="https://img.leeguoo.com/media/4afeca25-c867-41a2-bb16-3d213699f045/extract-flows-snoopy.png?v=3" alt="Two key-extraction flows: wechat-use uses the debugging interface at login time (no re-signing, no sudo, direct); wechat-decrypt first re-signs + sudo + scans memory for the key" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>To read the WeChat process’s memory, macOS puts up a barrier: WeChat is signed with <strong>hardened runtime</strong>, and by default the system does not allow another process to <code>task_for_pid</code> attach to it—<strong>even if you are root</strong>. The way the two tools get past this barrier is their biggest difference, and also the part where I made the messiest revisions in this article (I’ll explain below how I got it wrong twice).</p>
<h3>Route A: wechat-decrypt — Replace-Mode Re-Signing + sudo Memory Scan</h3>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">sudo codesign --force --deep --sign - /Applications/WeChat.app   # ① replace-mode re-sign as ad-hoc
cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation
sudo ./find_all_keys_macos                                        # ② root scans memory
</code></pre></figure>
<p><code>codesign --force --deep --sign -</code> is <strong>replace-mode</strong> re-signing: it changes the entire WeChat app to an ad-hoc signature, wiping out the hardened runtime along with WeChat’s <strong>own entitlements</strong>. Then it <strong>relies on root</strong> to use <code>mach_vm_read</code> to scan memory chunk by chunk (2 MB per chunk), regex-match the 96-hex key+salt pattern, and write hits into <code>all_keys.json</code>. It reads the salt separately from the DB file header. The idea is straightforward, standard practice in reverse-engineering circles, and <strong>fully open-source and auditable</strong>. The cost: <strong>it requires root</strong>, and replace-mode re-signing wipes WeChat’s original permissions too (in extreme cases, this can affect WeChat’s own TCC permissions).</p>
<h3>Route B: wechat-use — Read the Key Without Re-Signing (Debugging Interface at Login Time)</h3>
<p>This was the part I found most twisted at first, so let’s split it into two layers:</p>
<p><strong>Reading the key (<code>init</code>) itself does not require re-signing or sudo.</strong> At <strong>the instant WeChat logs in</strong>, it uses macOS’s public debugging interface to grab the decryption material once, then immediately detaches—no <code>codesign --force --deep</code>, no root. This is completely different from Route A, where the tool first does replace-mode re-signing and then brute-force scans memory as root.</p>
<p><strong>Re-signing is only needed if you want to send messages, and it is merge-mode.</strong> The <code>slot_send</code> adapter used for sending needs WeChat to have <code>get-task-allow=true</code>, so it runs one <strong>merge-mode</strong> re-signing step—<strong>only adding the single <code>get-task-allow</code> entitlement while preserving all of WeChat’s original permissions</strong> (SKILL explicitly warns not to use replace-mode, because that would wipe WeChat’s TCC permissions). This is where it is more restrained than Route A: even though it also touches the signature, it performs a surgical merge rather than a blanket replacement.</p>
<p>The output of <code>wechat-use doctor</code> on my machine:</p>
<figure class="t-codeblock" data-lang="text"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">text</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-text">✓ wechat_get_task_allow   get-task-allow = true (LLDB can attach)
✓ wechat_codesigned       signer: ad-hoc (locally re-signed, includes get-task-allow)
✓ wechat_dylib_fingerprint SHA-256=fc162db6…  raw_key ✓ (keymap)
✓ wechat_update_guard     ✓ disabled (Tencent cannot write to MacUpdate)
</code></pre></figure>
<p><strong>Be careful not to fall into this trap</strong> (I did): the “locally re-signed” shown here is because this machine of mine <strong>had been configured for sending</strong>, so it had already gone through that merge-mode re-signing step. It is <strong>not a prerequisite for reading the key</strong>—if you only read and do not send, the login-time interface used by <code>init</code> is enough. Two other details: <code>wechat_dylib_fingerprint</code> means it selects a keymap based on the WeChat dylib fingerprint to locate the raw key (more resistant to upgrades than brute-scanning a fixed pattern), and <code>wechat_update_guard: disabled</code> means it makes the <code>MacUpdate</code> directory unwritable so the re-signing will not be wiped out by an update.</p>
<p>One-sentence comparison for key extraction: <strong><code>wechat-decrypt</code> key reading = replace-mode re-signing + root brute-force memory scan; <code>wechat-use</code> key reading = login-time debugging interface, no re-signing and no sudo (only sending messages requires a separate merge-mode re-sign).</strong> For the key-reading step, <code>wechat-use</code> is clearly lighter and more restrained; <code>wechat-decrypt</code> is rougher, but fully open-source and consistent across platforms.</p>
<h3>A Clever Trick Worth Copying: Image Keys Do Not Need Memory Scanning</h3>
<p>There is a pretty clever detail in <code>wechat-decrypt</code>: <strong>image keys are not taken from process memory, but derived from the on-disk <code>kvcomm</code> cache</strong> (<code>find_image_key_macos.py</code>). WeChat stores the key for image <code>.dat</code> files in the disk cache, so decrypting images does not require attaching to the process or using root at all. If you want to implement image decryption yourself, this route is clean and worth copying directly.</p>
<h3>Decryption Itself: Identical on Both Sides, Just a Few Manual Lines</h3>
<p>Once you have dug out the raw key and paired it with the salt from the file header, there is no mystery left—<strong>it is all standard SQLCipher 4</strong>. After obtaining the key, manually decrypting a database only takes these few lines (key masked):</p>
<figure class="t-codeblock" data-lang="sql"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">sql</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-sql">$ sqlcipher contact/contact.db
sqlite> PRAGMA key = "x'&#x3C;64-character key hex…>'";   -- raw key dug from memory
sqlite> PRAGMA cipher_compatibility = 4;             -- declare SQLCipher 4
sqlite> .tables                                      -- can list tables = decrypted
</code></pre></figure>
<p>If the key is correct, <code>.tables</code> lists the contact tables; if the key is wrong, it directly reports <code>file is not a database</code>. <strong>You do not need to manually feed the salt</strong>—it is in the first 16 bytes of the file header, and SQLCipher reads it by itself. What both tools do is automate these few lines; all the previous work around re-signing/attach/memory scanning is only for obtaining that <code>x'…'</code>. <strong>Nobody has a proprietary decryption algorithm; “which one decrypts better” is a false question. The difference is entirely in the earlier key-extraction step.</strong></p>
<h2>After You Get the Database: Capabilities Diverge</h2>
<p><img src="https://img.leeguoo.com/media/90ec4ce4-f6ff-4ee9-9d93-f3698f903096/send-vs-dig.png?v=3" alt="Message-sending postman vs data-digging detective" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>Extracting the key is the entry ticket; after you have it, the two tools go in different directions. This table is my corrected version after hands-on testing (the crossed-out parts are where the first version was wrong):</p>

































































<table><thead><tr><th>Dimension</th><th>wechat-use</th><th>wechat-decrypt</th></tr></thead><tbody><tr><td><strong>Sending messages</strong></td><td>Headless, zero UI flash, with delivery status</td><td>Cannot send; read-only</td></tr><tr><td><strong>Reading databases</strong> (sessions/contacts/history/images)</td><td>Supported (local test: sessions returned normally)</td><td>Fully decrypts into plaintext SQLite</td></tr><tr><td><strong>Speech-to-text</strong></td><td>✅ Also supported (v1.17: silk-decoder + whisper-cli + ggml-medium)</td><td>✅ SILK → WAV → text</td></tr><tr><td><strong>Key extraction method</strong> (macOS)</td><td>Reads key via debug interface at login time; <strong>no re-signing, no sudo</strong> (message sending separately uses merge-mode re-signing)</td><td>replace-mode re-signing + <strong>sudo</strong> mach_vm brute-force memory scan</td></tr><tr><td><strong>Upgrade adaptation</strong></td><td>dylib fingerprint keymap + probe-build self-adaptation</td><td>Fixed memory patterns; waits for author updates</td></tr><tr><td><strong>Platforms</strong></td><td>macOS only</td><td>Win / Mac / Linux</td></tr><tr><td><strong>WeCom</strong></td><td>No</td><td>5.x tested only on Windows</td></tr><tr><td><strong>For Claude (MCP)</strong></td><td>Via HTTP/gRPC; you need to wrap it yourself</td><td>Native MCP, one-line integration, 20+ tools</td></tr><tr><td><strong>Real-time listening</strong></td><td>daemon RPC → SSE</td><td>Web UI SSE / CLI / MCP</td></tr><tr><td><strong>Remote access</strong></td><td>Exposes REST via Cloudflare Tunnel</td><td>Mainly local</td></tr><tr><td><strong>Cost</strong></td><td>Paid activation code</td><td>Free and open source</td></tr></tbody></table>
<p><strong>This is exactly where hands-on testing matters—and I was wrong more than once.</strong> In the first version, based on the docs, I wrote: “wechat-use does not re-sign and does not transcribe voice.” After running it, I saw <code>doctor</code> display “local re-signing,” thought I had caught an error, and changed it to “both sides must re-sign to read.” But that was an <strong>overcorrection</strong>: that re-signing was configured for <strong>sending messages</strong>; reading the key does not require re-signing at all. Only after reading the full SKILL docs and source code did the picture come together: <strong>reading the key does not re-sign; only sending messages uses merge-mode re-signing; transcription already existed in v1.17.</strong> I got it wrong once by reading the docs, then wrong again by looking at only half the evidence. Only after running the tools end to end and reading both the docs and source did I dare draw a conclusion.</p>
<p>One-sentence reading of this table: <strong><code>wechat-use</code> is an all-in-one bot platform for “sending + reading + transcription + real-time” (paid, closed source, macOS only), while <code>wechat-decrypt</code> is an open-source data tool for “decryption + export + transcription + native MCP” (free, cross-platform, supports WeCom, but cannot send messages).</strong></p>
<h2>Each Workflow</h2>
<h3>wechat-use (send + read + real-time)</h3>
<pre><code>① Subscribe to the TG channel to get an activation code
② wechat-use init          # Hooks the debug API at login to extract the key; no re-signing, no sudo (message sending separately uses merge-mode re-signing); daemon starts on demand
   ├─ Read: wechat-use sessions / history / contacts / search / image  (YAML, low-token)
   ├─ Send: wechat-use send "text" &#x3C;name or wxid>                         (zero-flash in background)
   └─ Real-time: wechat-use listen  or  wechat-bridge → HTTP /messages/stream?since=
③ Remote: wechat-use tunnel setup → expose REST via Cloudflare Tunnel
④ Upgrade: wechat-use probe-build → adapt to new builds
</code></pre>
<h3>wechat-decrypt (decryption + export + MCP, macOS)</h3>
<pre><code>① python venv + pip install -r requirements.txt + brew install whisper-cpp
② Quit WeChat → sudo codesign re-sign → cc compile → sudo ./find_all_keys_macos   # root scans memory to extract key
③ python decrypt_db.py            # full decryption → plaintext SQLite (-i for incremental)
   ├─ Export: python export_all_chats.py -t  # JSON/CSV/HTML + voice transcription + incremental/date windows
   ├─ Real-time: python main.py                 # Web UI localhost:5678 (SSE)
   └─ For Claude: claude mcp add wechat -- python mcp_server.py   # 20+ query tools
</code></pre>
<p><code>mcp_server.py</code> plugs into Claude with a single line; <code>get_chat_history</code> / <code>search_messages</code> / <code>decode_voice</code> query directly. When parsing XML such as Moments, it also adds <strong>XXE protection</strong> (rejecting <code>DOCTYPE</code>/<code>ENTITY</code> + size limits), which is more rigorous than many similar tools.</p>
<h3>Combining Them (real scenario: agent connects to WeChat)</h3>
<pre><code>Sending messages / real-time replies / group @ bot ──────────→  wechat-use   (mature, already in use)
Bulk history pulls / WeCom / native Claude queries ──→  wechat-decrypt MCP  (adds MCP + cross-platform)
</code></pre>
<p>They don’t conflict, but <strong>don’t run them at the same time</strong> — both need to attach to WeChat, and both touch its signature and DB. Running both stacks on one machine will make signing and attachment fight each other.</p>
<h2>Two Easily Overlooked Points</h2>
<p><strong>Voice-to-text works the same on both sides.</strong> WeChat voice messages aren’t MP3; they’re <strong>SILK</strong> (Skype’s open-source narrowband voice codec). So transcription is a three-stage pipeline: <code>SILK → decode to WAV → send to Whisper for text</code>. <code>wechat-decrypt</code> includes a SILK decoder plus whisper.cpp/OpenAI; <code>wechat-use</code> v1.17 also packages <code>silk-decoder + whisper-cli + ggml-medium</code>, so voice messages are automatically transcribed when reading group history. This chain is basically the reverse of doing TTS welcome messages — that side is text → voice, this side is voice → text — but both rely on Whisper.</p>
<p><strong>Personal WeChat and WeCom don’t use the same lock.</strong> Personal WeChat 4.x uses <strong>SQLCipher 4</strong> (the setup described earlier: file-header salt + in-memory raw key). WeCom 5.x is a different system: <strong>wxSQLite3 AES-128</strong>, with different encryption parameters and a different key location. That’s why <code>wechat-decrypt</code> has a separate <code>find_wxwork_keys.py</code>, and it has only been tested on Windows. <code>wechat-use</code> simply doesn’t touch WeCom. If you want to decrypt WeCom right now, the only practical path is <code>wechat-decrypt</code> + Windows.</p>
<h2>Risks and Boundaries (This Is a Decryption Tool, So It Needs to Be Clear)</h2>
<ul>
<li><strong>Both may modify WeChat’s signature, but in different ways and at different times.</strong> <code>wechat-decrypt</code> uses replace-mode re-signing just to read the key (wiping WeChat’s original entitlements) + root; <code>wechat-use</code> does not re-sign when reading the key, and only adds <code>get-task-allow</code> in merge mode when sending messages (preserving the original entitlements), while also proactively disabling auto-updates to keep it that way. Either way, ad-hoc re-signing changes WeChat from an official signature to a local signature, which theoretically may be detectable or trigger Gatekeeper warnings. Know what you are changing before you do it.</li>
<li><strong>Only decrypt your own account and your own data.</strong> The legitimate use case for tools like this is exporting/backing up your own chats, not reading someone else’s.</li>
<li><strong>Do not leak keys or chat contents.</strong> Once the raw key leaks, anyone can decrypt your database. All real keys and chat contents in this article are redacted — and that is what you should do when writing articles like this or posting logs.</li>
<li><strong>Developer Mode / attach permissions are required.</strong> <code>wechat-use</code> requires macOS Developer Mode + <code>get-task-allow</code>; <code>wechat-decrypt</code> requires root. These are both “can read arbitrary process memory” level permissions, so think carefully before granting them.</li>
</ul>
<h2>So, Is His Implementation Better Than Ours?</h2>
<p>Looking at it separately:</p>
<ul>
<li><strong>Key extraction: <code>wechat-use</code> is lighter and more restrained.</strong> It does not re-sign at all when reading the key (debug interface at the moment of login, no <code>sudo</code>); it only uses merge-mode re-signing when sending messages (adding only <code>get-task-allow</code> while preserving WeChat’s original permissions). <code>wechat-decrypt</code> has to use replace-mode re-signing + root hard scanning just to read the key, which is rougher and also wipes WeChat’s original permissions along the way — but it is <strong>fully open source, auditable, and uses one cross-platform approach</strong>.</li>
<li><strong>Decryption itself: the same.</strong> Both are standard SQLCipher 4; nobody has any black magic here.</li>
<li><strong>Capability coverage: each has its own strengths.</strong> <code>wechat-use</code> uniquely supports “sending messages”; <code>wechat-decrypt</code> uniquely supports “native MCP + cross-platform + WeCom.” Both support voice transcription (I got this wrong at first).</li>
</ul>
<p>The accurate way to put it is: <strong>this is not “whose implementation is better,” but two different paths.</strong> <code>wechat-use</code> wins on the elegance of key extraction and the ability to “send messages”; <code>wechat-decrypt</code> wins on being open source, free, cross-platform, and native MCP.</p>
<h2>Selection Conclusion</h2>
<ul>
<li><strong>If you need to send messages, you can’t do without <code>wechat-use</code>.</strong> <code>wechat-decrypt</code> simply cannot send messages, so it cannot replace it.</li>
<li><strong>What makes <code>wechat-decrypt</code> worth introducing are the two gaps it fills:</strong> free native MCP (letting Claude connect in one line and directly query chat history), plus cross-platform support + WeCom. Voice transcription is no longer its unique selling point—both sides have it.</li>
<li>Deployment: keep <code>wechat-use</code> as the main tool for sending + real-time + database reading; when you need “Claude to natively query WeChat data” or “read WeCom / run on Linux,” add <code>wechat-decrypt</code>’s MCP. Don’t run both stacks on the same machine at the same time.</li>
</ul>
<p>Technically, two things can be copied from the other side: deriving image keys from the on-disk <code>kvcomm</code> cache (no need to attach to the process), and using the <code>x'key+salt'</code> 96-hex in-memory format with a keymap for locating keys (more upgrade-resistant than brute pattern scanning). If you ever want to build your own or harden the system, these two tricks are ready-made.</p>
<p><strong>One final methodological note: the most valuable part of this piece is not the comparison table, but the process of being wrong twice—I got it wrong once after reading the docs, then overcorrected once after seeing half a piece of evidence (<code>doctor</code>’s re-signing), and only dared to draw a conclusion after running the tools thoroughly and reading the SKILL and source code in full. The most dangerous part of writing comparisons is not failing to read; it is writing after reading only halfway—and the source of the misleading claim was precisely the tool’s own documentation line about <code>no re-signing</code>, which failed to distinguish between “reading keys” and “sending messages.”</strong></p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/wechat-decrypt-vs-wechat-use/</link>
    <guid>https://blog.leeguoo.com/en/posts/wechat-decrypt-vs-wechat-use/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Mon, 06 Jul 2026 04:13:53 GMT</pubDate>
  </item>
  <item>
    <title>Three Things to Watch in Claude Account Suspensions: Region, IP Type, Request Fingerprints, and How to Self-Check</title>
    <description>Breaking down the recent wave of Claude account suspensions, it appears to be the overlap of three independently detectable axes: region, which is the only red line officially stated; IP type, including data center, VPN, proxy, and residential proxy; and steganographic watermarks hidden in dates when using relays. This article organizes how each axis is detected, how to self-check, and how to avoid issues, while also introducing a free detection page and a small tool for the status bar.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p><img src="https://img.leeguoo.com/media/dd1f1ff8-e826-414d-93e9-6c01e44b8942/blog-cover.png?v=3" alt="Cover: A single exit IP passes through three detection gates—region, IP type, and request fingerprint—before being aggregated into a risk assessment dashboard" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<p>Even now, many people still do not understand how they were caught up in the recent wave of Claude account suspensions. Some people changed networks and continued using it without issue, while others logged in again the next day and found their accounts gone. Even among people using relays in the same way and located in mainland China in the same way, the outcomes have differed.</p>
<p>I reviewed the mechanisms that can be examined: Anthropic’s official policies, methodology documents from several providers that handle IP reputation, real account suspension cases from the community, and watermarking code discovered through reverse engineering inside the Claude Code client. Looking at these together, I found that account suspensions are not inexplicable; they happen when several independently detectable axes overlap. This article breaks down those axes one by one. It explains what causes suspensions, how to self-check, and how to avoid them.</p>
<p>If you only want to know whether your own setup is at risk, open <a href="https://ip-check.leeguoo.com/" rel="noopener" target="_blank">ip-check.leeguoo.com</a>: it checks region, IP type and the browser timezone the watermark reads, in about ten seconds, no sign-up. The section “So how do you actually self-check?” below explains what each result means. The rest of the article explains why those are the things that matter.</p>
<h2>First, the Conclusion: Account Suspensions Are Based on Three Things, Not One</h2>
<p>Many people think, “If you use a VPN or relay, you will be suspended.” That statement is only half correct. In reality, risk is determined across three independent dimensions, each of which can potentially get you suspended on its own.</p>
<p>The first is region: which country your network exit is in. This is the only suspension trigger that Anthropic has officially stated. The second is IP type: whether it is residential, data center, VPN, proxy, or Tor. This dimension can be measured most accurately by vendor reputation databases, but it has not been officially stated. The third is the request fingerprint. If you are using a third-party relay, Claude Code embeds a steganographic watermark in the request that can be traced back to you. This dimension is completely independent from the first two.</p>
<p>Let’s look at them in order.</p>
<h2>Dimension 1: Region, the Only Red Line Officially Stated</h2>
<p>First, let’s look at what Anthropic itself says. In the support document “What should I do if I received a warning for policy violations?”, only three types of triggers are listed for account suspension: violations in usage content, violations of the Terms of Service, and account creation from unsupported regions.</p>
<p>Pay attention to the last one. Across the official document, there is not a single mention of VPNs, proxies, IP addresses, or shared connections. The only network-related concept that appears is “region.” Meanwhile, in a separate notice, Anthropic states that it prohibits service use in certain regions under its Terms of Service due to legal, regulatory, and safety risks.</p>
<p>Put simply, mainland China is not on Anthropic’s list of supported regions. Accessing Claude directly from a mainland Chinese IP is itself a violation of the Terms of Service, and this has nothing to do with whether that IP is clean.</p>
<p>This also explains a phenomenon many people find hard to accept. Their home broadband is clearly a clean residential IP, with no proxy used at all, so why is there still risk? The issue is not that it is “dirty,” but that it is in the “wrong place.” Even if a Beijing Unicom residential IP has a perfect reputation score of 0, as long as it is an exit point in mainland China, it is a red line for Claude. Many people use VPNs or relays precisely to get around this red line. But after bypassing the regional dimension, they then stumble over IP type and request fingerprint.</p>
<h2>Dimension Two: IP Type, the Dimension Vendors Can Measure Most Accurately</h2>
<p>Although the official side does not state this explicitly, the vast majority of cases seen in the community where “it worked after changing networks” point to the same issue: whether your exit IP is a data center, proxy, or VPN IP. As for how this dimension is judged, vendors specializing in risk management have published fairly detailed methodologies, so I will explain it according to those public documents.</p>
<p>First, look at the type. proxycheck.io classifies IPs by type using base scores. Data centers are 33 points, VPNs are 50 points, crawlers/Tor are 75 points, and public proxies and compromised hosts jump straight to 100 points. The higher the score, the greater the danger. This ordering matters. VPNs are riskier than ordinary data centers, and proxies are riskier still than VPNs. That is because a data center IP at least represents the state of “this server is connected to the internet normally,” whereas with a proxy or VPN, it is obvious that someone is trying to hide their identity.</p>
<p>Residential proxies are among the most troublesome categories. IP2Location’s classification includes a dedicated category called RES, residential proxy. This means someone is selling a residential broadband line as a proxy. This kind of IP looks exactly like an ordinary residential line in terms of geolocation and ASN, but it is used by countless strangers for all kinds of automation purposes, and its reputation is already in tatters. If you buy a cheap “residential IP” service, there is a high probability that it is this type, and its risk is higher than that of a data center.</p>
<p>Reputation has guilt by association. ipapi.is has a metric called <code>abuser_score</code>. It does not look at a single IP, but calculates the proportion of abusive IPs within the entire ASN/organization network. It is divided into five levels: above 20% is extremely high, 3% to 20% is high, 0.85% to 3% is somewhat high, 0.05% to 0.85% is low, and below 0.05% is extremely low. In other words, even if your specific IP is clean and has no direct history of wrongdoing whatsoever, if the broader network that IP belongs to is dirty, you will be held responsible by association. In the original explanation, the point is that although the IP itself is not flagged, given how many abusive IPs exist within its network, it is highly likely to be abused in the near future. That is how risk management calculates things. It does not look at who you are, but at who you are standing with.</p>
<p>Abuse history accumulates. AbuseIPDB assigns each IP an <code>abuseConfidenceScore</code> from 0 to 100. This is based entirely on community reports, and 100 means the IP is almost certainly malicious. The score is determined by the natural logarithm of the number of reports: the more reports there are, the higher the score becomes, though the growth gradually slows. It does not jump sharply from just one or two reports.</p>
<p>Combining these factors, the risk profile of an IP roughly looks like this: type is the foundation, and data centers, VPNs, and proxies each have their own baseline. On top of that are layered abuse history and network-level reputation, and the more they overlap, the redder the signal becomes. This is also the scoring logic of my detection tool. I will explain it later.</p>
<h3>Important Correction: A Data Center IP Does Not Automatically Mean You Will Be Blocked</h3>
<p>Here, I need to correct a misunderstanding I initially had myself. Many people hear “data center IPs are risky” and assume that running Claude on a cloud server will get them blocked, but that is not the case.</p>
<p>Running Claude Code on an AWS/GCP server, or calling the API from one, is a completely normal and supported use case. There is only one situation where a data center IP truly becomes dangerous: when you use it to log in to the web version of claude.ai or register a new account. Web login and registration are flows designed on the assumption that a real human is using a browser. When a data center IP appears there, it looks as if an automation script is cultivating accounts at scale.</p>
<p>Therefore, when judging data center IPs, you should not reject them across the board. If you are running an API or command-line tool, a data center is fine. For logging in to or registering on the web version, a data center is dangerous. Many detection tools fail to handle this distinction correctly, and the first version of my own tool did too.</p>
<h2>Third Axis: The Watermark Hidden Inside the Date</h2>
<p>The first two axes were about “how your network looks.” This axis is about “how Anthropic marks the request itself.” The material comes from <a href="https://x.com/chenchengpro/status/2072209406184526013" rel="noopener noreferrer nofollow" target="_blank">@chenchengpro’s reverse engineering of the Claude Code version 191 source code</a>, and I also checked it against the client on my own machine. The mechanism is real.</p>
<p>The trigger condition is that you have set a third-party relay via <code>ANTHROPIC_BASE_URL</code>, and that URL is not the official api.anthropic.com. Users connecting directly to the official endpoint are not affected at all. That is exactly why Chen Cheng says this watermark is not directly related to the recent wave of bans. The watermark is a tracking mechanism prepared for relay and distillation scenarios, not the ban itself.</p>
<p>Claude Code contains this line, assembled as the date inside the system prompt:</p>
<blockquote>
<p><code>return `Today${n}s date is ${r}.`;</code></p>
</blockquote>
<p>At first glance it looks harmless. But steganography is hidden in that <code>n</code>, meaning the separator between the apostrophe and the date, secretly encoding identity information across two axes.</p>
<p>One is the timezone. If the system timezone is <code>Asia/Shanghai</code> or <code>Asia/Urumqi</code>, the date separator quietly changes from <code>2026-06-30</code> to <code>2026/06/30</code>. The important point is that it reads the system timezone, meaning <code>Intl…resolvedOptions().timeZone</code>, not the IP geolocation.</p>
<p>The other is the apostrophe. There are four variants, almost indistinguishable to the naked eye.</p>
<ul>
<li><code>'</code> (U+0027 standard apostrophe), normal third-party endpoint</li>
<li><code>’</code> (U+2019), domain whitelist hit</li>
<li><code>ʼ</code> (U+02BC), hit on large-model-related keywords in mainland China</li>
<li><code>ʹ</code> (U+02B9), both domain and keyword hit</li>
</ul>
<p><img src="https://img.leeguoo.com/media/0f536ef5-84b3-4d37-8c3c-4cda5f56fddb/watermark-decode-en.webp?v=3" alt="A seemingly harmless sentence, Today&#x27;s date is 2026/06/30, hides 3 bits of identity information. The date separator (- or /) encodes the system timezone, while the four apostrophe glyphs encode whether the relay domain hit the .cn whitelist and the deepseek keyword" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>The three axes are encoded independently. Even if the relay domain is not on the whitelist and contains no keyword, if the system timezone is Shanghai or Urumqi, the separator still becomes a slash. In other words, all users with “China timezone + any third-party endpoint” are tagged on the timezone axis.</p>
<p>The matching logic has also been dug out. Domains are matched by suffix. The whitelist starts with <code>cn</code>, and domains ending in <code>.cn</code> are swept up wholesale without needing to be enumerated one by one. Keywords are matched by substring. As long as <code>"deepseek"</code> appears somewhere in the domain, it hits; exact matching is not required. The two lists are further obfuscated with XOR (<code>key=91</code>) and base64 to avoid string scanning.</p>
<p>Why choose the sentence “today’s date” as the carrier? This is the most insidious part. The mark is not hidden in an HTTP header or metadata, but inside the body of the system prompt. Relay servers usually rewrite or filter request headers, but almost nobody touches natural language like a “date.” In other words, this is a watermark that cannot be washed away by scrubbing headers. And because <code>currentDate</code> is a user-context field included in every request, the watermark appears with 100% stability. Changing the apostrophe and separator does not alter the meaning. To the model, it reads exactly the same, and even in a diff it is easy to miss.</p>
<p>How does the evidence loop close? The mark travels with the request. If a relay service or distillation pipeline eventually routes back to the official Anthropic API to resell Claude, the marked request returns to Anthropic’s own servers. Anthropic can then read this in its own logs: the date in this “directly connected to me” request is <code>2026/06/30</code>, meaning slash, so China timezone. The apostrophe is <code>ʹ</code>, meaning both the domain and the deepseek keyword hit. That is hard evidence. The origin is a client in a China timezone using a mainland-China large-model relay. There is no need for active probing. The traffic confesses on its own.</p>
<p><img src="https://img.leeguoo.com/media/3657d913-8528-409b-9023-3144aa096671/evidence-loop-en.webp?v=3" alt="A request carrying a steganographic mark leaves the user’s device, passes through a mainland Chinese .cn relay, and ultimately returns via resale to the official Anthropic API. Anthropic reads the same mark in its own logs and identifies the client as using a domestic relay from a China timezone" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>For ordinary users, the implication is very clear. As long as you use a third-party relay, your requests can be tracked by fingerprint. If you are in a mainland China timezone, you will definitely be marked. Even if your timezone is different, you will still be marked if the relay domain is <code>.cn</code> or contains the name of a mainland Chinese model. If you want to avoid being marked completely, there is no path other than connecting directly to the official endpoint.</p>
<h2>So how do you actually self-check?</h2>
<p>We’ve finished talking through the three dimensions. The question is: how do you know where the network you are using right now sits? Things like IP type, region, and reputation cannot be judged by eye.</p>
<p>I made a free check page for this. It is meant to answer one question: “Can I safely use Claude on this current network?”</p>
<p><strong><a href="https://ip-check.leeguoo.com/" rel="noopener" target="_blank">ip-check.leeguoo.com</a></strong></p>
<p>Open it and you can immediately see the assessment of your current exit IP. No registration, no need to enter anything. It does a few things.</p>
<p>The assessment has four categories, not some vague score: Safe (residential line and supported region), Caution (data center; depends on what you use it for), High Risk (VPN, proxy, Tor, or overlapping abuse signals), and Unsupported Region (mainland China, etc.). For each category, it tells you directly what to do instead of just throwing a number at you and making you guess.</p>
<p>Region takes priority over IP. Even if you have a clean residential IP in Beijing, it will not falsely show “Safe”; it will tell you “Unsupported region, direct access is risky.” This is the key point that makes an IP check truly Claude-oriented. For Claude, what country you are in matters more than how clean your IP is.</p>
<p>All signals are disclosed and shown. It lists whether the IP is in a data center, VPN, proxy, residential proxy, Tor, mobile network, and what stage the network-range abuse reputation is in. It also shows things that are not included in the score. Recently, I added a dedicated row called “China cloud.” Alibaba Cloud, Tencent Cloud, Volcengine, ByteDance, and similar providers are marked red on their own, even if the IP is geolocated to the United States and the location is shown as the U.S. What matters to Claude is not whether the IP is in the U.S., but whether the cloud behind it is a Chinese company. The warning in the mitigation list above — “U.S. exit + major domestic-provider domain is actually more dangerous” — is exactly what this row handles.</p>
<p>There are also two detections that run directly in your browser. Nothing is uploaded anywhere, and they correspond exactly to the watermarks described in the third dimension. The first is the browser timezone. The page directly reads <code>Intl.DateTimeFormat().resolvedOptions().timeZone</code>; if it hits a mainland China zone such as <code>Asia/Shanghai</code>, it warns you in red. This is the same value the watermark reads, and it is also the first gate in the entire signal chain. Once the timezone matches, it does not matter how clean the IP later in the chain is. The other is the real IP exposed by WebRTC. Many people do not know that even with a proxy set up, the browser’s WebRTC can leak the real public IP behind it. The page probes for this and directly displays the IP if it leaks.</p>
<p>Below that is a “local self-check” section. System proxy, proxy values left in environment variables, <code>ANTHROPIC_BASE_URL</code> — these are signals a website physically cannot read, but Claude Code reads them all from your machine the moment it starts. I cannot test these on your behalf, so I listed the commands you should run. Run through them in your terminal, and clear anything that is not empty.</p>
<p>There is also a Claude safety score and global ranking. The score is from 0 to 100; the higher it is, the safer. You can also see how much of the global detection results you outperform. It is mostly just for fun, but in practice some people do like this kind of comparison. Once the assessment is generated, you can also create a share image with one tap. It is your own card with the IP blurred, making it convenient to screenshot and send.</p>
<p>The scoring logic is exactly what this article has been explaining: proxycheck’s type criteria, ipapi.is’s network-range reputation classification, plus region assessment. The route for checking your own IP uses only pure Cloudflare edge data and local computation, and never touches any third party. That is why it does not go down and has no usage limit. Only when checking “someone else’s” IP does it add one layer of free threat intelligence.</p>
<p>You can also check another IP if you want. Use <code>ip-check.leeguoo.com/?ip=1.2.3.4</code>, or add the <code>Accept: application/json</code> header to get structured results.</p>
<h2>Mitigation: Action List by Impact</h2>
<p>Once self-checking has identified the risk points, the next question is how to avoid them. Here they are in order of importance.</p>
<p><strong>Region comes first.</strong> If your exit is in mainland China, or in another unsupported region, this is the only red line the official side has explicitly stated. Fix this first, and access from a clean residential connection in a supported region.</p>
<p><strong>Change your system timezone to avoid the detection list — this is the lowest-cost move.</strong> For the third watermark, the timezone dimension reads your system timezone, not your IP geolocation. If you are on Asia/Shanghai or Asia/Urumqi, change it to <strong>Asia/Taipei</strong>. It is in the same UTC+8 zone, so the displayed time is exactly the same as Beijing time, and calendars and schedules will still work normally, but it is not on the detection list. Taipei is also within Claude’s officially supported regions. Do not choose Singapore — a large number of secondhand accounts use the Singapore timezone, making it a conspicuous candidate for the next expansion of detection. On Mac, after changing it, remember to go to “Privacy &#x26; Security → Location Services → System Services” and turn off “Setting Time Zone.” Otherwise Apple may switch you back to Shanghai based on Wi-Fi location. Run <code>node -e "console.log(Intl.DateTimeFormat().resolvedOptions().timeZone)"</code> in the terminal; if it returns Asia/Taipei, you are done. This is the exact command Claude Code uses internally when reading the timezone. This practical procedure is organized based on <a href="https://x.com/app_sail/status/2072494971643715658" rel="noopener noreferrer nofollow" target="_blank">@app_sail’s anti-account-ban guide</a>.</p>
<p><strong>Do not log in to Claude or re-authenticate from data center, VPN, or proxy IPs.</strong> Operations like first login, registration, and redoing OAuth authentication are especially sensitive moments for risk controls. Running APIs or command-line tools on cloud servers day to day is fine, but authentication operations should be performed from a clean residential connection whenever possible.</p>
<p><strong>If you can connect directly, connect directly to the official endpoints.</strong> Use <code>api.anthropic.com</code> and <code>claude.ai</code>, and avoid third-party relays as much as possible. In addition to IP risk, relays also have the issue of the indelible fingerprint watermark mentioned earlier. If you are genuinely worried about account-ban risk, direct connection is the only way to address both IP and fingerprint at the same time. If you absolutely must use a relay or VPS, avoid overseas nodes from major Chinese providers such as Alibaba Cloud, Tencent Cloud, and Volcengine — the combination of “US exit + major Chinese company domain” may even produce a higher risk score than connecting directly from mainland China. It is essentially telling the service side that “Chinese users are using overseas nodes to bypass restrictions.” Choose hosting providers with independent domains and clean reverse DNS hostnames, such as Hostinger, Vultr, or Linode.</p>
<p><strong>Keep one account fixed to one clean network.</strong> Do not frequently switch across networks, and do not rapidly rotate multiple accounts on a single machine. These behavior patterns themselves become signals for risk control.</p>
<p><strong>Recheck regularly.</strong> VPN exits change. Residential IPs can be reclaimed and reassigned by carriers. A relay service you trusted may suddenly end up on a blacklist. Clean today does not necessarily mean clean tomorrow.</p>
<h2>By the way: a status bar for monitoring usage</h2>
<p>The above is for checking your network. If you use Claude Code, I also have a small tool I’ve been maintaining for a while. It lets you monitor limits, context, and billing pace from the status bar.</p>
<p><strong><a href="https://github.com/leeguooooo/claude-code-usage-bar" rel="noopener noreferrer nofollow" target="_blank">claude-code-usage-bar</a></strong></p>
<p>It does several things. It shows your 5-hour and 7-day limit windows with real-time progress bars, and also predicts roughly what percentage you are likely to consume within each window. When you switch models (from Sonnet to Fable), or run multiple agents in parallel, the prediction quickly follows the new consumption rate without lagging behind. You can see the context window, prompt-cache countdown, and how much this session has cost, all in one line.</p>
<p>The account suspension detection discussed above is also built in. If your exit IP is risky, the status bar directly shows a red warning saying, “Do not log in to Claude from this IP.” If you are using a relay and your timezone is set to China, it tells you that your requests carry a fingerprint. You do not need to open a page just to check your network. It watches for you.</p>
<p>Installation is one line.</p>
<pre><code>uv tool install claude-statusbar
</code></pre>
<p>Or you can use <code>pipx install claude-statusbar</code>, or <code>pip install -U claude-statusbar</code>. After installation, specify it in Claude Code’s status bar settings, and you are done.</p>
<h2>Finally</h2>
<p>Account suspension may sound mysterious, but when you break it down, it is not that opaque. Region is an official red line, IP type is a trust signal that providers can measure quite accurately, and request fingerprints are unavoidable markers when traffic goes through a relay. These three factors are independent of one another. Any one of them can trigger a problem, but each also has a way to mitigate it.</p>
<p>The least troublesome approach is to always connect directly to the official service and use a clean residential connection. That solves all three concerns at once. If that is not possible, spend just 10 seconds opening <a href="https://ip-check.leeguoo.com/" rel="noopener" target="_blank">ip-check.leeguoo.com</a> first, and check where you currently stand.</p>
<p>All tools are free, and the source code is public. If you run into any problems, please feel free to open an Issue.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/claude-account-ban-network-detection/</link>
    <guid>https://blog.leeguoo.com/en/posts/claude-account-ban-network-detection/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Fri, 03 Jul 2026 02:13:47 GMT</pubDate>
  </item>
  <item>
    <title>How to Build a Good AI Girlfriend</title>
    <description>Engineering notes from three months in production: typing rhythm, model selection, layered prompts, guaranteed-reply fallbacks, plus three reversals that took cache hit rate from 61% to 91%. Building an AI chat companion that does not break immersion is not about the model; it is about engineering details.</description>
    <content:encoded><![CDATA[<article class="article-content">
<h2>Obviously Fake, Lost on Rhythm</h2>
<p>An integration partner sent over a screenshot. The user had asked about a sensitive topic, and our AI girlfriend replied with six chat bubbles: "Haha," "Why are you suddenly asking about that~ I don't really understand politics," "I don't really follow that stuff either," "For example, what are your plans today?" "Talk about something else, okay?" and "😅". About 50 characters in total, with zero spacing, flooding the screen at the same instant. It did not look like chatting; it looked like batch output.</p>
<p><img src="https://img.leeguoo.com/media/8d042bca-b4b0-4c38-ad2a-4dca884da5b3/snoopy-typing-rhythm.png?v=3" alt="Six bubbles hitting at once vs. staggered typing rhythm" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<h3>Three Layers of Problems in One Screenshot</h3>
<p>Taken apart, those 50 characters contained three layers of problems. The most obvious was rhythm: every delay field sent downstream was 0, so all six messages appeared on screen at the same time. The splitting was wrong too. The point of splitting bubbles was to imitate how real people break one thought into several messages, but the splitting logic followed punctuation, cutting at every comma and period. An ordinary reply was chopped into six pieces, with even the 😅 occupying its own message. The remaining issue was content: the same "I don't understand politics" was repeated three times in different wording, followed by a forced "what are your plans today" counter-question trying to change the subject. Three apologies were squeezed together, with the information content of a single sentence. The content debt belongs to the prompt, and there is a dedicated chapter later for accounting for it; what immediately broke immersion for the user was the first two layers.</p>
<h3>How to Set delay</h3>
<p>When a real person is asked about a topic they do not want to answer, they pause for a few seconds first. What the other person sees is "typing," and then the messages arrive one by one. We fixed it in that direction: each message in the callback carries a delay field, and the downstream client uses that value to stagger the bubbles on screen. That leaves one question: what value should each message get?</p>
<p>The first message is always 0. Between the user sending a message and the first reply, model generation already takes time, so that blank space has already performed the role of "she is thinking" for us.</p>
<p>Each subsequent message simulates typing based on content length: a 500 ms base, plus 55 ms per character, clamped between 600 and 2600 ms. Then add random jitter of plus or minus 15%; otherwise, each interval is mechanically equal in length, which is another giveaway. Images are counted separately at 1800 ms. Browsing an album and picking a photo should be a beat slower than typing.</p>
<p>With this set of parameters applied, those six bubbles from the beginning start to look right: "Haha" pops out instantly, the longer sentence takes a while to type, and the 😅 lands lightly. The content has not changed by a single character, but most of the batch-processing feel is gone.</p>
<p>The upper bound also has to be managed. A single message is capped at 4000 ms, and the cumulative delay for the whole reply is kept within 16000 ms. The downstream protocol has a 20000 ms truncation line; delays beyond that line are clamped back inside it, which collectively shrinks and distorts the intervals that were previously arranged by character count.</p>
<h3>The Hard Part Is Not Making It Fast, but Making It Slow</h3>
<p>Before building this product, we thought the hard part would be making the AI fast. Once we started, we found it was the opposite: the hard part was making it "slow" in a human way. We even wrote an intentional first-response delay: for the first reply in the stranger stage, wait a random 4.5 to 10 seconds before sending it. Someone you have just met will not be staring at the screen waiting for you; their reply timing varies. If every message gets an instant response, it basically broadcasts "always online and standing by," which feels fake instead. How slow it should be, and when it should be slow, has no standard answer. You can only tune it bit by bit against how real chats look.</p>
<p>The three layers of problems after this each have their own fixes, but the principle is the same: building an AI chat companion that does not break immersion is not about the model; it is about engineering details.</p>
<h2>More Expensive Models Feel More Fake</h2>
<p>When building an AI chat companion, most people’s first instinct is to plug in the strongest model on the market. We thought the same at first. Later, we found that cost, personality, and content boundaries all failed to line up.</p>
<p><img src="https://img.leeguoo.com/media/c5543428-051c-4f65-a022-c4a62372fbf8/blog-model-tradeoff.png?v=3" alt="Tradeoff: strongest model vs. suitable model" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>Start with cost. Look at one main chat call: input is 11–12k tokens, while output is only 36–250. Heavy input, light output. Every round resends an almost unchanged system prompt and message history. Our primary model sits at the input price tier of $0.09 per 1M tokens, while frontier assistant models are one to two orders of magnitude more expensive. That price gap is not being spent on “smarter replies”; it is being burned on more than ten thousand tokens of repeated input.</p>
<p>Then there is personality. Frontier models are trained to be “helpful assistants,” and those training traces are all liabilities in a chat-companion scenario: customer-service tone, apologies, disclaimers, and the classic “As an AI, I can’t...” In an office assistant, these are seat belts. In role-play, every one of them breaks immersion. Users do not need her to write code; they need her not to suddenly turn into customer support halfway through a conversation.</p>
<p>The boundary issue is the least negotiable. This category includes adult-oriented content, and adult-oriented dialogue triggers refusals heavily on assistant models. The first two problems can still be suppressed somewhat with prompts, but this one cannot: refusal itself is the biggest immersion breaker, more fatal than any customer-service tone.</p>
<h3>Three Switches in Three Months</h3>
<p>The first generation used a domestic Mimo-series model, connected directly through the official channel. In the middle we tried MiniMax. In June 2026, we fully switched to deepseek-v4-flash, moving 25 personas over in one pass, with rollback SQL prepared before the switch.</p>
<p>What drove this final migration was a backlog of user complaints on the old model: repetitive AI-flavored responses and conversations drifting off-topic midway. These problems could not be moved at the prompt layer no matter how we tuned it. After testing, the conclusion was that they were model-driven, not prompt-driven: with the same prompt and a different model, a whole batch of previously untunable issues disappeared at once. Fixing prompts means picking through problems one by one; switching models solves them in bulk, provided you first identify which layer the problem is actually in.</p>
<p>This judgment is easy to get backward. After switching to DeepSeek, one persona kept aggressively bringing up “top-ups.” Our first reaction was, again, to blame the model. After tracing it down, we found that the persona override written by operations explicitly required her to be “materialistic”; the model was only following instructions. So do not rush to conclusions: when a reply feels wrong, first check the prompt and persona config. Confirm that they are not asking for exactly that behavior before suspecting the model. Wrongly blaming the model costs you one wasted migration; wrongly blaming the prompt means you will never tune it right.</p>
<h3>Two Cheap Levers</h3>
<p>After choosing the primary model, there are still two places to save money, neither of which requires sacrificing quality.</p>
<p>One comes from session distribution. Pulling production data showed that 84.5% of sessions ended after just one round. In the first five rounds, dialogue history is short and context is shallow, so a cheap fast model is good enough; switch to the primary model starting from round six. In code, this is just a one-line turn-count threshold config, cutting costs by 30% to 50%.</p>
<p>The other is in quality-check calls. Every reply goes through a small-model audit before leaving the system. What it checks and how it falls back are covered in detail in the chapter on human feel; here we only care about the call shape: around 650 input tokens and an output of only 2 tokens for the verdict. For this kind of heavy-input, light-output call that only needs a “pass or fail,” a flash-level model is cheap enough to run on all traffic, with no need for sampling.</p>
<h3>The Gateway Layer</h3>
<p>Once the model is decided, there is another choice: connect directly to the official provider, or go through an aggregation gateway. We used a gateway: one key connects to a dozen-plus vendors hosting the same model, so there is no need to sign contracts one by one, and we can pin the desired vendor by order. But this convenience has a cost: you introduce a new failure domain called “vendor routing” into the system. Which provider the request actually lands on, whether the cache still recognizes it—both gain another layer of uncertainty. In the later chapter on cache optimization, half the pitfalls come from this layer.</p>
<h2>Prompts Are Layered, and So Are the Lessons</h2>
<p>Our system prompt was over 13,000 tokens after assembly, but it was never a single continuous text. It was nested layer by layer. The innermost layer was the red lines hardcoded in code: identity declarations, output filtering, anti-injection. No tenant could change them; changing them meant changing code. The middle layer was the tenant-level “general behavior layer,” a large block of 13,449 characters that governed speaking style, refusal style, how messages were segmented, and so on. All personas shared this layer. Only at the outermost layer did each persona’s own description come in, and that was only four or five hundred characters. Next to the persona sat a structured block of “hard facts”: numeric facts like age, city, and family were listed separately instead of being buried in prose. After that came dynamic blocks, assembled on the fly based on the current state: time, relationship stage, scenario, and conversation memory.</p>
<p><img src="https://img.leeguoo.com/media/995e3b0b-3cca-47dc-8759-4d2e24c4e5ba/blog-prompt-onion.png?v=3" alt="Five-layer onion structure of a prompt: from code red lines to dynamic blocks" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>What is worth writing down are the four lessons learned from stepping on each layer.</p>
<h3>91% Was Constraints, So New Instructions Sank to the Bottom</h3>
<p>For a while, we wanted certain personas to loosen up a bit, so we added instructions to the prompt, but the model’s behavior did not budge. Later, after doing the numbers, we understood why: out of the prompt’s 13,000-plus tokens, constraint-type content made up 91%. A newly added sentence saying “loosen up,” soaked in pages of “must not,” was as good as unsaid.</p>
<p>The fix was to restructure constraints by level: the level number itself was not fed to the model. Instead, code translated each level into concrete instructions and then assembled them into the prompt. What the model saw was an explicitly written behavior description, not a secret code it had to decipher on its own. This lesson landed in the general behavior layer: once text piles up to this scale, the translation work has to be handed over to code.</p>
<h3>Example Sentences Get Copied Wholesale</h3>
<p>We once put literal example sentences in the prompt, intending them to mean “talk with this kind of feel.” The model treated them as answers and repeated them word for word. Real users noticed within days: “Why does she keep saying the same sentence?”</p>
<p>Later, all examples were replaced with abstract category descriptions and sentence-shape descriptions, letting the model fill in the words itself. Demonstration should stop at the “shape” layer. One step more specific, and the model will copy it.</p>
<h3>For Fixed Sentences Repeated Verbatim, Grep the Code Before Blaming the Model</h3>
<p>There was a piece of safety code that matched replies with a regex. Once it hit, it replaced the entire sentence with hardcoded copy. In real traffic, 5–8% were false positives: the user’s previous sentence had been perfectly normal, and the next reply suddenly threw out a fixed sentence that had nothing to do with the context. It looked like the model had gone off the rails, but that sentence had actually been swapped in by code.</p>
<p>When investigating this kind of issue, we built up one rule of thumb: whenever a “fixed sentence repeated verbatim” keeps appearing, grep the code for hardcoding first, and only suspect the model after that. Model output is random; even when expressing the same meaning, the wording drifts each time. If something repeats with every punctuation mark identical, it most likely comes from code. After we fixed this false positive, the 5–8% dropped to 0. This lesson sits in the innermost layer: red-line code can make mistakes too.</p>
<h3>Hard Facts Can Turn Into Topic Black Holes</h3>
<p>We gave one persona a specific occupation and favorite drink. The model treated these facts as available material: whenever it had nothing to say, it talked about them. Users ended up hearing her mention the same drink every day.</p>
<p>Hard facts still need to be provided; personas cannot be hollow. But they need to come with a “topic saturation” constraint: if the same type of persona fact has been discussed recently, it must not be brought up proactively again.</p>
<h2>Humanness Is Accumulated</h2>
<p>Humanness does not grow out of writing “please chat like a real person” in the prompt. After three months, what we had accumulated was a pile of unremarkable little mechanisms, each responsible for only a small slice of the experience.</p>
<h3>She Has to Know What Time It Is</h3>
<p>The model has no clock of its own. If you do not tell it, there is no “now” in its world. So we inject three things into the prompt: the current time, the time of the previous message, and how long has passed between them. With those three lines, when the user comes back in the morning, she knows how late they chatted last night, that a whole night has passed in between, and her first sentence can pick up properly.</p>
<p>We stepped on a bug here. One time, “previous message time” was mistakenly written as the current time, so the interval always calculated to zero. The AI therefore always thought the user had just sent a message: you come back after a whole night, and her tone when continuing the conversation sounds as if you had just said the previous sentence. This kind of mistake does not throw an exception. The code keeps running as usual. The only thing that breaks is the feeling of the conversation.</p>
<h3>The Relationship Cannot Be Clingy From the Start</h3>
<p>First, the crash scene: one time, the model spat “we are still in the stranger stage” back to the user verbatim. That sentence was internal state we fed to it, not dialogue. Once the user saw it, the illusion broke instantly.</p>
<p>This label came from our relationship-stage machine. Stranger, acquainted, familiar, close: four levels, advanced only by number of chat turns, not by chat content. Each level corresponds to a set of wording and intimacy. Counting only turns sounds crude, but it preserves one thing: the very first sentence after meeting someone will not be sticky and overly affectionate. The fix was to add a hard ban in the prompt: labels must never appear in replies; closeness and distance may only be expressed through tone. Any internal state fed to the model should be assumed to eventually be spat back out verbatim.</p>
<h3>Messages Need to Be Short</h3>
<p>Normal people do not send 100-character mini-essays in one breath when chatting, so there is a length gate at the reply exit: Chinese triggers it above 100 characters, Vietnamese above 200. The Vietnamese threshold is higher not because it is more verbose, but because the words are simply longer for the same meaning.</p>
<p>Once triggered, it runs in two steps. First, a small model compresses it. Compression is not regeneration: the instruction embeds the original draft and asks it to keep only the single most important point. Second, there is a fallback: if it cannot be compressed, truncate at sentence boundaries. Truncation is done by code. The model will occasionally ignore your word-count requirement; code will not.</p>
<h3>Every Reply Goes Through an Auditor</h3>
<p>Time, stage, and length each handle one segment. At the exit there is one more master gate: before every reply is sent out, it must pass through a small model we call the “auditor.” The input is the reply plus the persona’s hard facts. The output is only a two-token judgment, checking three things: whether it breaks character, whether it leaks the prompt, and whether the facts are confused. If it hits, rewrite once. If the rewrite still fails, use the fallback.</p>
<p>Running quality control on every message has a cost, which we calculated in the chapter on model selection: flash-level models are cheap enough to run on every message. The tiny amount saved by spot-checking is not worth the cost of letting one illusion-breaking reply slip through.</p>
<h3>The Silence Mechanism Later Completely Reversed</h3>
<p>We originally had a <code>[NO_REPLY]</code> mechanism: if the model output this marker in a round, the system would not send a reply. The design intention was that when the user spammed messages, the AI could choose not to respond, handling it coldly like a real person. Real people do not react to every single thing you send either.</p>
<p>Later, this was connected to a per-message billing scenario. Every message the user sent cost money, so this mechanism immediately became untenable: silence meant the user had paid and received no echo. Customer complaints came straight from there. From then on, only one rule remained: it must reply under all circumstances.</p>
<h2>Reply No Matter What</h2>
<p>We had an error-reporting group. We pulled three days of data: 50 alerts in total. Runtime CPU limit exceeded and forced resets happened 20 times, storage instance drift 6 times, upstream model API failures 8 times, and the rest were various quality alerts. In the group, they looked like four categories of problems, with four sets of technical causes. But on the user side, they all collapsed into the same path: the message was sent, and the reply never arrived. The synchronous API returned a direct 500; the async pipeline sent an error callback, but the user side displayed nothing, not even an error bubble; the trickiest case was when generation was halfway through and the whole instance got killed, so even the error-handling code never had a chance to run.</p>
<p>So the direction of the fix was not to first eliminate the four categories of errors one by one, but to guarantee that no matter which category blew up first, the user could still receive a reply.</p>
<p><img src="https://img.leeguoo.com/media/dfe96cc1-d0df-4807-be1d-e0f568a5d6a4/snoopy-always-reply.png?v=3" alt="Four categories of errors fall into a three-layer fallback net and eventually become one reply" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<h3>Fallbacks Have to Press Down Layer by Layer</h3>
<p>The fix was layered fallback, one layer pressing down on the next, with the bottom layer not allowed to depend on the LLM. When an error happens, first randomly pick a sentence from a preset phrase pool, with deduplication to avoid picking the same sentence twice in a row. Fallback lines are already short; if the exact same one appears twice consecutively, it feels even faker than not replying. If the pool is empty, ask the model to generate a lightweight topic-shift line on the spot. If the model is down too, randomly pick one from a static short-sentence pool grouped by language, something like, "Hmm? What did you just say? Say it again?" The bottom layer has to be static: one of the scenarios the fallback must catch is "upstream model API failure." Calling the model again at that point means betting everything on the very link that just failed.</p>
<p>There had been an earlier implementation of this final layer: 30% of the time it replied with a question mark, and 70% of the time it simply stayed silent. Later, when investigating customer complaints about "the AI not replying," we traced them one by one, and the source was exactly that 70% silence. That branch was removed entirely and replaced with the short replies that the layers above could fall back to.</p>
<h3>What If Even Error Handling Dies?</h3>
<p>The hardest scenario was when the instance got killed midway through generation: the whole instance was gone, and the error-handling code died with it. No matter how complete the try-catch was, it did not help. We solved it with leases. When a task starts, mark it as "generating" and attach a lease period. After the instance comes back, scan once. If a task's lease has expired, it means the previous run died halfway through. At that point, do not generate again. Rerunning would mean duplicate billing and duplicate records. Instead, send out a fallback short reply. The user waited a little longer, but they did get a reply.</p>
<h3>Two Specific Pits</h3>
<p>The head-of-line blocking incident was a painful lesson. Async callbacks were delivered in strict sequence-number order. As a result, one callback that could never be delivered got stuck at the head of the queue, all later messages piled up behind it, and the entire conversation froze for 3.7 hours. Strict ordering has an implicit premise: every item can be delivered. Once that premise breaks, ordering stops protecting the experience and starts dying along with it. The fix was to give up absolute ordering: once the head item has retried up to the limit, let it go, deliver the later items out of order, and let the upstream reorder them by sequence number.</p>
<p>The other pit was buried in the deduplication logic, the "avoid picking the same sentence twice in a row" part of the phrase pool. To determine whether fallback phrases were duplicates, we used longest common substring, O(n²), with no length limit. Usually it was fine, until one abnormally long reply came in and blew straight through the single-instance 30-second CPU budget, causing the platform to forcibly reset the instance. This pit was inside the fallback itself: the deduplication meant to ensure the user received a reply ended up killing the instance instead. The fix was simple: in production, string algorithms need input limits. Do not trust "the input won't be that long."</p>
<p>There was a coincidence on the day the fallback went live. One minute after deployment, we ran a smoke test and happened to hit a transient error caused by an instance reset during a code update. In the past, that would have been a 500. That day, it returned a natural fallback short reply. The fallback verified itself.</p>
<h2>Do the Math Before Cutting</h2>
<p>We pulled a week of bills: $29.69, 517 million tokens, 63,000 requests. Spread across each user message, the full-chain cost was about $0.0008. 99.9% of the money went to one primary model.</p>
<p>Break it down one more layer. The shape of the main chat call, as covered in the model selection chapter, was: over ten thousand input tokens, at most two or three hundred output tokens. More than 95% of the cost was in input, and 80% of that input was system prompt and history—every turn, we were paying to resend the same pile of nearly unchanged text.</p>
<p>Large model vendors provide prompt caching for this scenario: exact-prefix matching, with matched portions priced at roughly 1/5. This rule has no flexibility at all. If any single character in the prefix changes, everything after it is invalidated. We inspected our own prompt against this rule and found four cache killers.</p>
<p><img src="https://img.leeguoo.com/media/9e8213ff-e9ee-47e3-89ee-1189c9f237d3/snoopy-cache-layers.png?v=3" alt="Static blocks are cacheable prefixes; the time block that changes every minute needs to move to the end" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<h3>Four Cache Killers</h3>
<p>The worst one was the time block. It was precise down to the minute and also included a line like "37 seconds since the last message." It changed on every call, yet sat in front of several thousand tokens of static content, so one change wiped everything out. The fix was to keep time only to the hour and bucket the interval: the model does not need to know whether the user was gone for 37 seconds or 42 seconds. It only needs to know the difference between "just now" and "overnight."</p>
<p>The relationship-stage block contained a literal turn counter, which changed every turn. Advancing the relationship stage by turn count is internal logic; the model only cares which stage it is currently in. Changing it to buckets of ten made it stable.</p>
<p>The third one was hidden deeper: context compression had no hysteresis. Once the window filled up, it deleted middle messages by salience. As soon as the window was full, it re-deleted every turn, and the deletion positions differed each time. The history prefix of deep-chat sessions changed every turn. Deep-chat sessions were exactly the calls with the longest histories and most expensive inputs, so caching was permanently invalidated for them. We changed it to cut a larger batch at once, then leave it untouched for a dozen-plus turns.</p>
<p>The last one was purely an ordering issue: dynamic blocks like time and scene were placed before static content, pushing the large cacheable portion behind the invalidation line. We moved all of them to the end of the prompt.</p>
<p>After these four changes, not a single word of business semantics changed. We were purely making room for the cache.</p>
<h3>9 Seconds, or 9%</h3>
<p>There was another calculation on the vendor side. The same model was hosted by more than a dozen providers, and their prices and speeds varied wildly: the cheapest one generated at 22 tokens/s, nearly the slowest of the whole field, meaning a 200-token reply took 9 seconds to generate; the second tier generated at 70 tokens/s and cost only 9% more. Making users stare at "typing" for 9 seconds, or spending 9% more—there was no hesitation. We chose speed. That decision also had another benefit we did not pay much attention to at the time: prefix caches are isolated by provider. The more stable the primary provider, the fewer switches, and the higher the hit rate.</p>
<h3>Once the Math Was Done, Most Cuts Were Unnecessary</h3>
<p>At this point, we ran into a counterintuitive conclusion: the whole thing cost $127 a month. Even cutting it in half would only save $60. Any optimization with a risk of quality regression was negative ROI. Engineers see a 13k-token system prompt and their hands itch, but changing an already tuned prompt to save a few dozen dollars is not worth even the regression testing labor.</p>
<p>So we rejected the two big moves that looked most worth doing: compressing the 13k system prompt, no; changing audit calls to sampling, also no. In the end, we kept only the four cache changes, because they had zero impact on output.</p>
<p>Once the four-piece set was designed, we prepared to ship it. At the time, we thought this work was already wrapped up.</p>
<h2>From a 61% to 91% Hit Rate, with Three Reversals in Between</h2>
<p>The four-piece set had zero impact on output, so once it shipped, the hit rate should have started climbing. The actual curve was much uglier, with three twists along the way.</p>
<h3>Reversal One: On Launch Day, the Numbers Did Not Move</h3>
<p>After the four-piece set went live, we watched the dashboard that day, and the hit rate was still hovering in the old 61% to 77% range. The first reaction was that the optimization had not taken effect, and the prompt restructure had been pointless.</p>
<p>The truth had nothing to do with the four-piece set. The same release had bundled another change: we switched the primary provider to prioritize speed. Prefix caches are isolated by provider. The cache you built up with provider A is completely invisible to provider B. Switching providers is equivalent to a full cold start for the entire cache, wiping out the gains from prompt optimization.</p>
<p><img src="https://img.leeguoo.com/media/bb7c2fc8-b985-49e8-b805-308a5dd93aaa/blog-bundled-release.png?v=3" alt="Two changes bundled into one release, contaminating each other&#x27;s readings" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>This left us with two rules. First, provider changes and prompt changes must always be released separately; if they are bundled together, attribution becomes impossible. Second, never draw conclusions from a single-hour snapshot. In the first hour after the switch, the provider ranked first in the config got only 4.3% of traffic, and we almost concluded that “routing config was not taking effect.” But when we expanded the window to a full 22 hours, it had actually received 56%. Hourly fluctuation is normal; look at at least 24 hours before saying anything.</p>
<h3>Reversal Two: The Provider with the Lowest Sticker Price Was the Most Expensive After Calculation</h3>
<p>Once the cold start passed, the next question was which provider to choose. The provider details page in the aggregation layer had an Effective Pricing table, showing the actual cache hit rate for the same model across providers based on 30 days of real-world traffic. The gap was shocking: the best was 81.4%, the worst 11.3%.</p>
<p>The provider with the lowest surface-level price, input at $0.09/1M, had only a 21% real-world hit rate across the network. Plug it into the formula, and it was actually one of the most expensive. The formula is short:</p>
<p>Effective unit cost = hit rate × cache read price + (1 − hit rate) × input price</p>
<p>Use this to choose providers, not the sticker price. Sticker price only covers the second half of the formula. For providers with high hit rates, most traffic goes through the first half: the much cheaper cache read price.</p>
<p><img src="https://img.leeguoo.com/media/e1acf923-3dbd-43b4-971c-290a338259ca/blog-pricing-iceberg.png?v=3" alt="Provider pricing iceberg: sticker price above water, actual cost below" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>Why do hit rates differ so much? It depends on where each provider stores its cache. One provider uses implicit caching for 5 minutes, with each hit automatically renewing the cache, and its docs state this clearly. Most hosting providers do not disclose TTL; they keep cache in GPU VRAM and use LRU, so under load it can be evicted within minutes. The only long-lived option is the model’s official source, which uses disk cache and can last from hours to days. Companion-chat traffic has exactly this pattern: users come back after a night away and continue the conversation. Long TTL catches these calls; hosting-provider caches at the 5-minute level do not help.</p>
<p>There was also a hidden trap here. Once we manually pinned provider order, the aggregation layer’s sticky routing was disabled — the mechanism that automatically pins the same session to the same provider to preserve cache. The docs mention it in only one small line; we only understood it after stepping on it in production.</p>
<h3>Reversal Three: After Switching to the Official Source, Traffic Was Zero</h3>
<p>After running the formula, the official source was the best answer: 81.4% network-wide hit rate, cache read price only 2% of input price, and 10× cheaper than second place. We changed the config, shipped it, and watched the dashboard waiting for the hit rate to take off.</p>
<p>Traffic to the official source was zero. All requests landed on the second-priority provider, and retry count was always 1. This was not retry-after-failure; the routing layer had never treated the official source as a candidate at all.</p>
<p>After a long investigation, the issue turned out to be the account’s privacy setting: by default, it blocked “paid endpoints that may use request data for training.” The official source was on that list. This was not a misconfigured setting, but a real tradeoff: user conversation data might be used for training in exchange for cutting cost by 40%. This kind of switch should not be casually flipped by an engineer during debugging; it needs a decision-maker to approve it.</p>
<p>One debugging note: if traffic to a newly added provider is zero, check account-level filters first — privacy settings, blocklists, things like that. Only after checking those should you suspect a bad config.</p>
<h3>Final Battle Report</h3>
<p>Daily hit rate: before the switch, 61% to 67%; first day after the switch, 80.8%, then 87.2%, 86.5%, with a peak of 91.1%. Unit cost dropped from $0.068/M prompt tokens to $0.026/M, down 62%; by message count, cost per thousand messages dropped from $0.324 to $0.164, down 49%.</p>
<p><img src="https://img.leeguoo.com/media/8c5bd46d-b3fb-45d6-a07b-007ea8da173a/blog-cache-hitrate-curve.png?v=3" alt="Daily cache hit rate: climbing from 61%–67% to 91%" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<p>The last task was attribution. Spend = message volume × cost per message. These two factors have to be measured separately, otherwise you cannot answer the question: “Did the bill drop because of the optimization, or because traffic changed?” We ran into this exact issue. During the week when the bill fell off a cliff, we broke it down and found that 89% came from traffic, while the optimization’s real contribution was the 49% drop in unit price. Without that split, this report would have claimed credit for a traffic decline. Then when traffic came back and the bill rose again, we would have had to explain why the “optimization stopped working.”</p>
<h2>A Few Rough Troubleshooting Rules</h2>
<p>Over three months, we also accumulated a few unwritten troubleshooting rules. Behind each one was a bout of wasted effort.</p>
<h3>Error Cards Need Session IDs</h3>
<p>For platform-level errors like CPU limit exceeded, the default alert only includes the tenant and path. You know something blew up, but not which session blew up. So we added a layer to error handling: when throwing an error, include the <code>conversation_id</code> and a preview of the user message. In the first week after adding it, we used it to pin down three “live sample” sessions. The profile was the same: one session kept exploding, then recovered on its own — a typical transient issue.</p>
<p><img src="https://img.leeguoo.com/media/144496ab-2f03-4e2a-98f7-1c0a0def18ec/blog-error-card-detective.png?v=3" alt="Error cards need session IDs before troubleshooting has any clues" decoding="async" referrerpolicy="no-referrer-when-downgrade" loading="lazy"></p>
<h3>Check Before Asserting</h3>
<p>We tripped up twice, both in the same place: trusting stale memory instead of checking real-time status. Once, we thought “the change hasn’t been released yet,” when it had already gone out. Another time, we thought “the bug hasn’t been fixed yet,” when it had actually been fixed that same day. Both times, we spent half a day investigating from the wrong premise. After that, we made a rule: deployment status is judged only by deployment records. Memory expires. Docs say “what we planned at the time.” Only deployment records say “what it actually is right now.”</p>
<h3>Don’t Draw Conclusions From One Smoke Run</h3>
<p>The regression suite has 48 cases. Run the same code twice, and a ±3 difference in score is normal — pure noise. Running it once and declaring “it regressed” or “it’s fixed” is like writing a report based on one coin toss. The rule now: run at least 5 times before drawing a regression conclusion. If the score is right on the passing line, run it 10 times. A few extra runs don’t cost much; rework from a wrong conclusion does.</p>
<h3>Count Upstream 500s and Empty Replies Separately</h3>
<p>When the upstream model has issues, monitoring tends to lump them together, but they’re actually two different illnesses. One is a real API error, <code>finish_reason=error</code>. The other is that the model ends normally, but the content is empty: <code>finish_reason=stop</code> with empty <code>content</code>. The former should alert and retry as usual. The latter has its own temperament: during supplier switching windows, it briefly rises, then falls back on its own after a couple of days. After getting burned by this, we split the two metrics apart and agreed: in the first two days after switching suppliers, don’t rush to fix a “new bug” just because empty replies went up. Most likely it’s turbulence from the switch itself. Wait for it to recover; if it doesn’t, then intervene.</p>
<hr>
<p>Looking back on these three months: the model changed three times, suppliers changed twice in one month, and the prompt kept changing too. Through all the swapping, what didn’t get swapped out was this set of habits: do the math before cutting, watch the data after release to verify it, bring the session ID when investigating problems, and check deployment status before investigating.</p>
<p>In this business, the model is procured, and the persona copy comes from operations. What engineers truly deliver are the invisible details above.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/how-to-build-an-ai-girlfriend/</link>
    <guid>https://blog.leeguoo.com/en/posts/how-to-build-an-ai-girlfriend/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 02 Jul 2026 06:59:05 GMT</pubDate>
  </item>
  <item>
    <title>One Real Chrome, Several Agents, and You: How to Keep Everyone from Clicking Each Other’s Stuff</title>
    <description>chrome-use’s selling point is that it lets agents use your one real browser. But that brings a problem that tools which open a new browser do not have: several agents plus you may all be acting inside the same Chrome at the same time. This article explains how it isolates work by “who opened the tab” (colored tab groups + a dedicated daemon + only touching its own pages), and one trade-off that can trip you up if left unsaid: this isolation only works through the extension connection path; a bare --cdp port is not isolated.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>chrome-use’s selling point is that it lets agents use <strong>your one</strong> real browser. But that selling point comes with a problem: tools that open a new browser give each task its own separate browser, so they naturally stay out of each other’s way. chrome-use does the opposite: there may be several agents, plus you yourself, all acting inside <strong>the same Chrome</strong> at the same time. Who clicked whose tab, who navigated whose page away — it has to solve these problems, otherwise “using your browser” turns into a disaster.</p>
<p><img src="https://img.leeguoo.com/media/721ff51d-d314-405f-ad58-1b81b0a6a831/multiagent-hero.webp?v=3" alt="Three colored tab groups in one Chrome window: agent A and agent B each have their own group, while your own tabs are in a separate group; two little black agents each click only their own group and do not touch your tabs" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<h2>The Problem Is More Concrete Than It Seems</h2>
<p>Suppose you have Chrome open and are reading documentation, while two agents are running in the background: one helping you go through Slack messages, the other scraping data from some admin panel. All three parties are in this one browser.</p>
<p>If they operate on the “current tab” without distinction, things immediately become chaotic: the login page opened by agent A gets treated by agent B as its own target and clicked; the tab you are reading gets navigated somewhere else by some agent. The new-browser approach does not have this problem, because each task has its own process. If chrome-use wants the same non-interference while “sharing one real browser,” it needs another mechanism.</p>
<h2>Its Solution: Isolate by “Who Opened It”</h2>
<p>chrome-use does not hand each agent a browser. Instead, it divides ownership inside the same Chrome. Each <code>--session &#x3C;name></code> gets three things: a <strong>colored tab group</strong> (so you can tell at a glance in Chrome which group belongs to which agent), a dedicated background daemon, and one hard rule: it <strong>only operates on tabs it created itself</strong>.</p>
<p>So each party does its own thing: agent A’s pages are in A’s tab group, agent B’s are in B’s group, and nobody touches your own tabs. You do not even have to name every agent; if you do not, it automatically derives a stable session name from the agent. Tabs have stable IDs within a session (<code>t1</code>, <code>t2</code>, …, never reused once used), so actions like “switch back to the tab I was just on” are deterministic and do not get mixed up. When you are done, <code>chrome-use close</code> (or <code>close --all</code>) clears the tabs for that session without touching your others.</p>
<h2>A Trade-Off That Can Trip You Up If Left Unsaid</h2>
<p>There is a key detail here. The official docs mention it briefly, but it is easy to miss: <strong>this session-based isolation only holds on the “extension connection” path; if you connect directly with a raw <code>--cdp &#x3C;port></code>, it is not isolated.</strong></p>
<p>The reason is that isolation depends on the browser extension to manage tab groups and ownership. A bare CDP port connection does not have that layer. Several connections see the same pile of tabs, and anyone can click anyone else’s. This connects neatly to the other side of the Electron article I wrote earlier: Electron apps can only use <code>--remote-debugging-port</code> (they cannot install the extension), so on Electron you <strong>do not get</strong> this multi-agent isolation. That path never had the extension as a safety net in the first place.</p>
<p>This also explains why chrome-use insists on using the extension plus native messaging in browser scenarios, instead of taking the easy route and connecting directly to the debugging port. Avoiding the “Allow remote debugging?” prompt is only the surface-level benefit. More materially, only the extension path can support “multiple agents each governing themselves inside one real browser.”</p>
<h2>One More Layer: Isolation Inside the Page Too</h2>
<p>Isolation is not only at the tab level. The operations chrome-use injects into pages run in an <strong>isolated world</strong>: they can read the page’s DOM, but cannot touch the page’s own JavaScript global variables. The benefit goes both ways: your automation logic does not accidentally step on the page script’s globals, and the page script cannot detect what you injected. This is on the same line as “not being recognized by anti-bot systems”: the fewer traces, the better.</p>
<h2>When You Actually Need This</h2>
<p>For a single agent and a single task, you do not really need to think about any of this. The default automatic session name is enough. It starts to matter when you begin running multiple agents <strong>in parallel</strong>, and they share this one already-logged-in browser of yours. For example, one resident assistant is sending and receiving messages, and you temporarily ask another agent to look something up. At that point, give each one its own <code>--session</code>, and all three parties (two agents plus you) can work in the same Chrome at the same time without fighting.</p>
<p>In the end, tools that open a new browser get isolation by “opening several browsers.” chrome-use insists on “just using your one real browser,” and builds isolation into it through ownership and tab groups. The former is simpler. The latter gives you this in return: your entire logged-in state is directly available to several agents, without each of them having to log in again separately.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-multi-agent-one-real-chrome/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-multi-agent-one-real-chrome/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 02 Jul 2026 06:47:08 GMT</pubDate>
  </item>
  <item>
    <title>Getting Web Data for Agents: I Tried Three Clumsy Methods and Kept Only One</title>
    <description>I tried three ways to fetch data: stuffing a full HTML page into context, doing one find/get round trip per field, and hand-writing eval scripts. All felt awkward. What remains is chrome-use’s site and extract, based on the same judgment: data extraction should be a single declarative evaluation, not a chain of imperative round trips. This article explains how to use both from real output, how to write an extract schema correctly (I got it wrong at first), and where it cannot or should not be used.</description>
    <content:encoded><![CDATA[<article class="article-content">
<p>I tried three clumsy ways to get web data for agents.</p>
<p>The most direct one is to stuff the entire page HTML into the model and let it read for itself. A medium-sized page can take tens of thousands of tokens; before the data is even extracted, half the context is already gone, and the model often reads across lines incorrectly. The second is to write CSS selectors yourself: one <code>find</code> and one <code>get</code> per field. A dozen fields means a dozen round trips, which is slow and verbose. The third is to hand-write a piece of JS <code>eval</code> and run it inside the page. It works, but every page needs its own rewrite, and you have to handle attributes and multiple values yourself.</p>
<p>After going in circles, the two commands I kept are from chrome-use: use <code>site</code> for known sites, and <code>extract</code> for arbitrary pages. Behind them is the same judgment: data extraction should be a single <strong>declarative</strong> evaluation, not a chain of <strong>imperative</strong> round trips.</p>
<p><img src="https://img.leeguoo.com/media/dc4b3b83-f883-4adc-88e1-c9ae6de89666/extract-hero.webp?v=3" alt="One command retrieves clean JSON from a real logged-in Chrome, while the three old paths—full-page HTML, broken selectors, and anti-bot walls—are crossed out" decoding="async" referrerpolicy="no-referrer-when-downgrade" fetchpriority="high"></p>
<h2>site: For known sites, don’t write selectors yourself</h2>
<p>Open a site that chrome-use recognizes, and it will tell you that an adapter is available:</p>
<pre><code>$ chrome-use open https://news.ycombinator.com
💡 site adapters for news.ycombinator.com — prefer these for structured data:
   hackernews/thread, hackernews/top
</code></pre>
<p>An adapter is prewritten extraction logic for a specific site. You do not need to care what HN’s DOM looks like or how many times it has changed:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">chrome-use site hackernews/top --json
</code></pre></figure>
<p>Each item returned is complete, not just a title:</p>
<figure class="t-codeblock" data-lang="json"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">json</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-json">{ "rank": 1, "title": "ZCode – Harness for GLM-5.2", "url": "https://zcode.z.ai/en",
  "author": "chvid", "score": 329, "comments": 258, "id": 48753715,
  "hn_url": "https://news.ycombinator.com/item?id=48753715" }
</code></pre></figure>
<p>The real value of an adapter is that it moves the problem of “page structure may change” away from you. If HN redesigns, the adapter gets fixed, and your <code>site hackernews/top</code> command does not change by a single character. The cost is that it only covers the built-in sites. Run <code>chrome-use site</code> first to see what is available.</p>
<p>Two small pitfalls you really can run into: the data is in <code>.data.result.posts</code>, not at the top level of the response, so with <code>jq</code> you need to write the full path; chrome-use prints a version notice to stderr, which does not affect the JSON on stdout. If you want it absolutely clean, add <code>2>/dev/null</code>.</p>
<h2>extract: For arbitrary pages, provide a selector schema</h2>
<p>For sites without a ready-made adapter, use <code>extract</code>. First, one misunderstanding needs correcting. I got this wrong at the beginning too: it is <strong>not</strong> “describe what you want in natural language.” Instead, you provide a schema made of <strong>CSS selectors</strong>:</p>
<pre><code>{
  "rows": "&#x3C;css>",           // With rows: repeated container → returns an array; without it → returns one object for the whole page
  "fields": {
    "name": ".title",                          // Shorthand: selector → trimmed text
    "href": { "sel": "a", "get": "@href" },    // Get an attribute
    "tags": { "sel": ".tag", "get": "text", "all": true }  // Keep every match → array
  }
}
</code></pre>
<p><code>get</code> can be <code>text</code> (default), <code>@attribute</code>, <code>html</code>, or <code>value</code>. To scrape each title and link from the HN front page, the row container is <code>.athing</code>:</p>
<figure class="t-codeblock" data-lang="bash"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">bash</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-bash">chrome-use extract --schema '{"rows":".athing","fields":{"title":".titleline a","url":{"sel":".titleline a","get":"@href"}}}'
</code></pre></figure>
<figure class="t-codeblock" data-lang="json"><figcaption class="t-codeblock__head"><span class="t-codeblock__lang">json</span><button type="button" class="t-codeblock__copy" data-copy-code aria-label="Copy code">copy</button></figcaption><pre><code class="language-json">[ { "title": "ZCode – Harness for GLM-5.2", "url": "https://zcode.z.ai/en" },
  { "title": "Oomwoo, an open-source robot vacuum you build yourself", "url": "https://makerspet.com/blog/..." } ]
</code></pre></figure>
<p>If you omit <code>rows</code>, it returns one object for the whole page. That works well for page-level fields such as <code>h1</code> or <code>link[rel=canonical]</code>.</p>
<p>The part worth thinking about one level deeper is how it executes: the entire schema is <strong>compiled into one page evaluation</strong>, not the earlier pattern of one round trip per field. A dozen fields still come back in one trip. It is fast, and the behavior is deterministic: apply the same schema to the same page, and the result is stable and reproducible. That is why declarative extraction feels smoother than imperative extraction: you describe the structure, and it computes everything once. Not sure how to write the selector? Use <code>snapshot -i</code> to inspect the structure first, or click the target element in DevTools to get its selector.</p>
<h2>What it cannot extract, and when it should not be used</h2>
<p>The single-container model also has places it cannot reach. HN’s score is not in the <code>.athing</code> row; it is in the following <code>.subline</code> row. So the example above only gets the title and link. Any structure where “one record is split across two DOM rows” cannot be fully extracted with the <code>rows</code> / <code>fields</code> model. That is exactly why adapters exist: <code>site hackernews/top</code> already stitches those two rows together.</p>
<p>Conversely, some situations should not open a browser at all. For a few fields from a public static page, <code>curl</code> + <code>jq</code> is faster. If you want to know “what pages exist,” use a search engine. The real home turf for <code>extract</code> / <code>site</code> is pages that require login, run JS, or get blocked by anti-bot systems. Because chrome-use operates on the real Chrome you are currently logged into, the data is already rendered in front of you. It is just reading what is on the screen into JSON. In a CreepJS test, the bot score was 0%: not because of disguise, but because it really is your browser.</p>
<p>The hard part of data extraction is never parsing. It is first getting to the point where parsing is possible. Let your own browser handle the first half: login, rendering, and passing detection. For the second half, <code>site</code> gives you assembled records, and <code>extract</code> lets you cleanly extract everything once with a selector schema.</p>
  </article>]]></content:encoded>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-extract-structured-json/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-extract-structured-json/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 02 Jul 2026 06:29:33 GMT</pubDate>
  </item>
  <item>
    <title>Desktop Apps Are Actually Web Pages: Once You Realize This, Operating Them Is No Longer “Desktop Automation”</title>
    <description>The first time I connected chrome-use to the Slack desktop app and got a snapshot full of named buttons, I was taken aback. Later it clicked: the Slack desktop app is a web page to begin with. An Electron app is the Chromium rendering engine wrapped in a native shell, with a clearly structured DOM inside. This article explains why that turns the traditionally messy work of “desktop automation” into operating a web page, and one trade-off you need to know compared with browser scenarios.</description>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-drive-electron-desktop-apps/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-drive-electron-desktop-apps/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 02 Jul 2026 06:27:32 GMT</pubDate>
  </item>
  <item>
    <title>Anti-Bot Detection Counts How Many Times You Lie: Why Real Browsers Are Naturally Flawless</title>
    <description>Anyone doing browser automation will eventually fall into the same trap: desperately trying to disguise a fake browser as a real human, only to be exposed layer by layer. To understand this, you first need to know that detectors like CreepJS do not directly decide whether you are a bot. They collect dozens of signals from your browser and cross-check them. Every mismatch counts as a lie. This article starts with a real fingerprint I tested, breaks down the hardest trace to hide, which lives at the protocol layer rather than the JS layer, and explains why patching is a negative-sum game.</description>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-why-real-chrome-not-a-bot/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-why-real-chrome-not-a-bot/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 02 Jul 2026 06:20:25 GMT</pubDate>
  </item>
  <item>
    <title>Choosing a Browser for Agents: Playwright’s Approach and chrome-use Are Not the Same Thing</title>
    <description>I compare chrome-use’s connection to an existing Chrome session with the new and persistent contexts used by Playwright, Puppeteer, and browser-use, explaining session state, tab ownership, connection requirements, and the limits of fingerprint testing.</description>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-vs-playwright-puppeteer-browser-use/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-vs-playwright-puppeteer-browser-use/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 02 Jul 2026 05:35:51 GMT</pubDate>
  </item>
  <item>
    <title>Changing Network Requests and Responses: A Detailed Guide to chrome-use network route</title>
    <description>During development and debugging, want to mock an unfinished API, change a request parameter, or modify a real response? chrome-use network route intercepts directly in the Chrome instance you are driving, with four uses: mocking responses, changing requests, modifying real responses, and blocking. This article explains why it is the lightest path—built on CDP Fetch, with zero JS injection, no added extension permissions, and no Chrome Web Store re-review—and includes real-device tests.</description>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-network-mock-rewrite/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-network-mock-rewrite/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 01 Jul 2026 06:25:04 GMT</pubDate>
  </item>
  <item>
    <title>Writing “Unit Tests” for the Frontend: A Detailed Look at chrome-use test</title>
    <description>chrome-use test turns manual browser checks into repeatable YAML suites that can run in CI, effectively writing unit tests for the frontend. This article explains what it is, why its implementation can be so thin, and how to use it with cookie-use to switch back and forth between multiple accounts.</description>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-test-suite/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-test-suite/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 01 Jul 2026 03:43:55 GMT</pubDate>
  </item>
  <item>
    <title>Letting an Agent Click Through “Sign in with Google” by Itself — chrome-use Chews Through OAuth Popup Login</title>
    <description>I wanted an AI agent to log into accounts by itself. The username-and-password path worked with bitwarden-use, but the OAuth path got stuck for most of a day. This explains how the three windows behind “Sign in with Google” talk to each other, why automation breaks as soon as it touches them, the three cuts chrome-use made, and the final fully automatic login to X.</description>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-oauth-popup-login/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-oauth-popup-login/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Tue, 30 Jun 2026 12:43:13 GMT</pubDate>
  </item>
  <item>
    <title>Synology DSM SSH Port Still Won’t Open After Enabling It: A Port Drift Retrospective</title>
    <description>After enabling SSH or changing its port in the Synology DSM panel, the port may still fail to open. The issue is not necessarily the password, account, or router port forwarding; DSM’s frontend state and an old port configuration in sshd_config may be layered together.</description>
    <link>https://blog.leeguoo.com/en/posts/synology-ssh-port-2222-10022-postmortem/</link>
    <guid>https://blog.leeguoo.com/en/posts/synology-ssh-port-2222-10022-postmortem/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Sat, 27 Jun 2026 05:17:57 GMT</pubDate>
  </item>
  <item>
    <title>Claude Code Scared Itself: A Prompt Injection False Alarm</title>
    <description>We spent the whole night chasing hooks, proxies, packet captures, and session logs, only to find that the scariest piece of “injection evidence” was not tool output, but a story Claude had told itself.</description>
    <link>https://blog.leeguoo.com/en/posts/claude-code-hallucinated-prompt-injection/</link>
    <guid>https://blog.leeguoo.com/en/posts/claude-code-hallucinated-prompt-injection/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Sat, 27 Jun 2026 04:56:38 GMT</pubDate>
  </item>
  <item>
    <title>chrome-use: Let Any AI Agent Directly Drive Your Logged-In Real Chrome, with CreepJS Rating It 0% Bot</title>
    <description>Stop launching automated browsers from a blank profile, logging in again, and running into CAPTCHAs. chrome-use lets any AI agent directly drive your real Chrome, where you’re already logged in to everything: CreepJS rates it 0% bot, and with structured snapshots plus an @ref interface, reading a page costs only 200–400 tokens without burning money on screenshots.</description>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-drive-your-real-chrome/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-drive-your-real-chrome/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 25 Jun 2026 02:14:43 GMT</pubDate>
  </item>
  <item>
    <title>Is Codex Really Secretly Burning Through Your SSD? I Dug Through My Own Machine</title>
    <description>People are sharing claims that Codex secretly writes logs like crazy and burns through SSDs. Local test: the file stays stable at 126 MB thanks to rolling cleanup, but over 10 days it inserted 8.45 million rows, and the writes came from the background desktop app-server. The original post is basically directionally correct; only the “everyone will definitely burn through an SSD in a year” part is exaggerated. Includes root cause (source code + GitHub issue) and cleanup options.</description>
    <link>https://blog.leeguoo.com/en/posts/codex-ssd-logs-investigation/</link>
    <guid>https://blog.leeguoo.com/en/posts/codex-ssd-logs-investigation/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 24 Jun 2026 01:55:28 GMT</pubDate>
  </item>
  <item>
    <title>You Can’t Import a GSI by Itself</title>
    <description>A prod DynamoDB table had its billing and indexes changed directly, bypassing CloudFormation, and the whole stack ended up stuck in rollback for five days. A write-up of what caused the drift and how it was fixed, plus one counterintuitive point: CFN import works at the whole-resource level.</description>
    <link>https://blog.leeguoo.com/en/posts/cloudformation-drift-cant-import-a-gsi/</link>
    <guid>https://blog.leeguoo.com/en/posts/cloudformation-drift-cant-import-a-gsi/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Tue, 23 Jun 2026 09:39:51 GMT</pubDate>
  </item>
  <item>
    <title>Let Claude Code Automatically Generate Images with Your ChatGPT Subscription: No API Key, and How It Gets Around Turnstile</title>
    <description>Let agents like Claude Code and Cursor generate images along the way when writing docs or READMEs, without an OPENAI_API_KEY, without paying extra, and using the ChatGPT subscription you already have. This article dissects the most interesting implementation path behind it: the web backend. Why you can’t just POST directly, why the real wall among the three layers of anti-scraping is the single-use Turnstile token, and the complete flow for driving your already-logged-in Chrome to generate images.</description>
    <link>https://blog.leeguoo.com/en/posts/chatgpt-imagegen-web-backend/</link>
    <guid>https://blog.leeguoo.com/en/posts/chatgpt-imagegen-web-backend/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Mon, 22 Jun 2026 06:52:38 GMT</pubDate>
  </item>
  <item>
    <title>Let Claude Code Draw the Image Itself: The Original Intent and Principles Behind chatgpt-imagegen</title>
    <description>When an AI agent needs an image while writing, the traditional path either requires an API key and money, or a human has to go to ChatGPT, generate the image, and paste it back in—the agent can only get stuck waiting. chatgpt-imagegen lets the agent generate images on its own using your existing ChatGPT subscription: no API key required, no Codex quota consumed by default, and image-to-image generation supported. This article explains its original intent, how its two backends work, and why it was designed for agents.</description>
    <link>https://blog.leeguoo.com/en/posts/chatgpt-imagegen/</link>
    <guid>https://blog.leeguoo.com/en/posts/chatgpt-imagegen/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 18 Jun 2026 03:32:47 GMT</pubDate>
  </item>
  <item>
    <title>$5 a Month, Withstanding 860K RPS and Still Not Topped Out — Architecture Notes and an Honest Postmortem for a Hotspot KV</title>
    <description>A second-level hotspot KV running on Cloudflare Workers (starting at $5/month): multi-region load testing pushed the read path past 860K RPS with zero worker errors, while the load generators hit their bottleneck first. This article explains its design, the full investigation of two real incidents (the first time we thought we had fixed it; the second time we found the real root cause: reads and writes were squeezed into the same queue of the same single-threaded object), and the final solution: separate reads from writes, plus redefining what “strong consistency” actually means.</description>
    <link>https://blog.leeguoo.com/en/posts/edge-kv-singleflight-lottery/</link>
    <guid>https://blog.leeguoo.com/en/posts/edge-kv-singleflight-lottery/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 18 Jun 2026 03:00:25 GMT</pubDate>
  </item>
  <item>
    <title>Enoshima Overnight Guide: Katase River Mouth Before Dawn → Main Breakwater at Daybreak (Fri 6/19)</title>
    <description>A one-page Friday overnight fishing guide for Enoshima: start at 1:00 a.m. at the Katase River mouth targeting seabass at night (rising tide + light/dark boundary), move at 04:35, and fish the morning prime time for bluefish at the main breakwater from 5:00. Includes tides and an overnight timeline, real maps (night spot + main breakwater + 24h parking + walking transfer route), tactics for the night session and morning prime time, lure/bait setup, solo night-fishing safety limits, and parking.</description>
    <link>https://blog.leeguoo.com/en/posts/enoshima-overnight-guide/</link>
    <guid>https://blog.leeguoo.com/en/posts/enoshima-overnight-guide/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 17 Jun 2026 08:03:49 GMT</pubDate>
  </item>
  <item>
    <title>Maihama / Old Edogawa Seabass Guide (Sat 6/20)</title>
    <description>A one-page guide to shore casting for seabass on Saturday evening around Maihama / the Old Edogawa: tides and departure time, fishable shore sections marked on a real map, how to use three types of lures, four indicators for reading the current, early signs before casting, a decision tree for changing spots, and post-rain judgment.</description>
    <link>https://blog.leeguoo.com/en/posts/maihama-seabass-guide/</link>
    <guid>https://blog.leeguoo.com/en/posts/maihama-seabass-guide/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 17 Jun 2026 06:44:51 GMT</pubDate>
  </item>
  <item>
    <title>Letting Agents Click into Cross-Origin iframes: chrome-use Takes On a Tough Problem</title>
    <description>When connecting an AI agent to a browser, the hardest part is not opening pages—it is the forms hidden inside cross-origin iframes. The agent can read them and fill them in, but it just cannot click “Save.” A look at how this tough problem was solved.</description>
    <link>https://blog.leeguoo.com/en/posts/chrome-use-cross-origin-iframe/</link>
    <guid>https://blog.leeguoo.com/en/posts/chrome-use-cross-origin-iframe/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 17 Jun 2026 05:30:25 GMT</pubDate>
  </item>
  <item>
    <title>Maihama Black Seabream Fishing Quick Reference · 6/20 Ebb-Tide Session</title>
    <description>A practical quick reference for black seabream fishing at the Maihama river mouth in Tokyo Bay—ebb-tide window, lure/bait priority for VIB and T-tail Texas rigs, and a decision tree for cutting losses after getting skunked. Intel comes from angler-group field tests on 6/15 + tide verification from the JMA Tokyo station. Includes three schematic images in an old MS Paint mouse-drawn style.</description>
    <link>https://blog.leeguoo.com/en/posts/maihama-kurodai-cheatsheet/</link>
    <guid>https://blog.leeguoo.com/en/posts/maihama-kurodai-cheatsheet/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Mon, 15 Jun 2026 09:43:06 GMT</pubDate>
  </item>
  <item>
    <title>iphone-use: Let AI Agents Control a Real iPhone</title>
    <description>Open-source real iPhone automation with a custom XCTest runner, CLI, HTTP API and MCP: read the screen, tap, type, remotely control and replay workflows.</description>
    <link>https://blog.leeguoo.com/en/posts/iphone-use/</link>
    <guid>https://blog.leeguoo.com/en/posts/iphone-use/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Thu, 11 Jun 2026 14:10:55 GMT</pubDate>
  </item>
  <item>
    <title>In-Depth Analysis of Claude Code Workflows: After Moving Orchestration into Scripts</title>
    <description>Claude Code released dynamic workflows yesterday (2026-05-28), and it is still a research preview. This article does not discuss source code; it only explains what it actually changes, when it is worth using, when it is purely a waste of tokens—and the essential differences between it and subagents / skills.</description>
    <link>https://blog.leeguoo.com/en/posts/claude-code-workflows-deep-dive/</link>
    <guid>https://blog.leeguoo.com/en/posts/claude-code-workflows-deep-dive/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Fri, 29 May 2026 09:38:38 GMT</pubDate>
  </item>
  <item>
    <title>Is My Claude Code Prompt Cache Still Warm? How Many Minutes Are Left, and Why 5 vs 60 Minutes Matters</title>
    <description>How the status bar works out whether Claude Code’s prompt cache is still warm and how many minutes are left, when the default 5-minute TTL is right, why 1-hour cache users see “cold” 55 minutes too early, and where the countdown can lie.</description>
    <link>https://blog.leeguoo.com/en/posts/claude-statusbar-cache-countdown/</link>
    <guid>https://blog.leeguoo.com/en/posts/claude-statusbar-cache-countdown/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Fri, 29 May 2026 05:00:05 GMT</pubDate>
  </item>
  <item>
    <title>Tokyo Commuting Area Home Purchase Candidate List</title>
    <description>A comparison page of home purchase candidates focused on long-term owner-occupancy, commuting, disaster preparedness, and budget pressure</description>
    <link>https://blog.leeguoo.com/en/posts/tokyo-house-candidates/</link>
    <guid>https://blog.leeguoo.com/en/posts/tokyo-house-candidates/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 27 May 2026 08:57:48 GMT</pubDate>
  </item>
  <item>
    <title>ChatGPT Subscription → Image Generation API Visual Overview: Understand chatgpt-imagegen at a Glance</title>
    <description>A one-page visual overview: how a ChatGPT subscription is turned by chatgpt-imagegen into a command-line image generation tool—OAuth token retrieval, Responses API SSE streaming, capability boundary matrix, and scenarios where you should not use it, all explained at a glance.</description>
    <link>https://blog.leeguoo.com/en/posts/chatgpt-imagegen-visual-guide/</link>
    <guid>https://blog.leeguoo.com/en/posts/chatgpt-imagegen-visual-guide/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Tue, 26 May 2026 07:54:43 GMT</pubDate>
  </item>
  <item>
    <title>Facing AI Audits: How Can You “Legalize” Your Reverse Engineering Project?</title>
    <description>Examines a practical approach to handling decompilation projects by combining Claude Code and Gemini CLI, focusing on the challenge of ownership verification and resolving it through an automated sanitization process.</description>
    <link>https://blog.leeguoo.com/en/posts/ai-assisted-sanitization-workflow/</link>
    <guid>https://blog.leeguoo.com/en/posts/ai-assisted-sanitization-workflow/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Tue, 26 May 2026 07:38:09 GMT</pubDate>
  </item>
  <item>
    <title>Dissecting the Implementation of the /goal Command</title>
    <description>Based on the Claude Code 2.1.139 headless source code, this breaks down how the /goal command drives the Agent to keep working until a condition is met through a session-level Stop hook.</description>
    <link>https://blog.leeguoo.com/en/posts/goal-command-implementation/</link>
    <guid>https://blog.leeguoo.com/en/posts/goal-command-implementation/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Tue, 12 May 2026 01:45:55 GMT</pubDate>
  </item>
  <item>
    <title>claude --bg Explained: How Claude Code Runs Background Agents, the Daemon and FleetView</title>
    <description>What actually happens when you run claude --bg, /bg or claude agents: one on-disk job model, a shared daemon (with bg-spare and bg-pty-host helpers) and the FleetView TUI, traced through the Claude Code 2.1.139 source.</description>
    <link>https://blog.leeguoo.com/en/posts/claude-agents-bg-implementation/</link>
    <guid>https://blog.leeguoo.com/en/posts/claude-agents-bg-implementation/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Tue, 12 May 2026 01:41:01 GMT</pubDate>
  </item>
  <item>
    <title>How I Confirmed the Local Database Unlocking Path in WeChat macOS 4.0.1.52</title>
    <description>An engineering-focused forensic review on my own device: from static files and runtime open paths to SQLCipher parameters, ultimately confirming the local database unlocking path and account-level key distribution model in WeChat macOS 4.0.1.52.</description>
    <link>https://blog.leeguoo.com/en/posts/wechat-macos-db-runtime-analysis-20260323/</link>
    <guid>https://blog.leeguoo.com/en/posts/wechat-macos-db-runtime-analysis-20260323/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Mon, 23 Mar 2026 05:37:24 GMT</pubDate>
  </item>
  <item>
    <title>From Assisted Coding to Integrated R&amp;D: Putting AI Programming into Team Practice (PPT)</title>
    <description>A Slidev-style Chinese presentation translated into English, explaining Agentic Coding, Skills and MCP, AGENTS.md and CLAUDE.md, sandboxing and permission controls, and how a team can truly operationalize AI programming with Plans, a YApi Skill, and docs-sync.</description>
    <link>https://blog.leeguoo.com/en/posts/ai-coding-agentic-coding-team-practice-ppt-20260318/</link>
    <guid>https://blog.leeguoo.com/en/posts/ai-coding-agentic-coding-team-practice-ppt-20260318/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 18 Mar 2026 08:25:35 GMT</pubDate>
  </item>
  <item>
    <title>From Coding Assistance to Agentic Coding: How Teams Can Truly Put AI Programming to Work</title>
    <description>A systematic overview of the core questions around Agentic Coding, covering the main forms of today’s tools, the boundary between Skills and MCP, how AGENTS.md and CLAUDE.md work, sandboxing and permission control, and how to evaluate AI programming with real tasks and benchmarks rather than demos alone.</description>
    <link>https://blog.leeguoo.com/en/posts/agentic-coding-skill-mcp-agents-md-claude-md-principles-20260318/</link>
    <guid>https://blog.leeguoo.com/en/posts/agentic-coding-skill-mcp-agents-md-claude-md-principles-20260318/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 18 Mar 2026 05:28:12 GMT</pubDate>
  </item>
  <item>
    <title>How QClaw Works: How It Turns OpenClaw into a Desktop Application</title>
    <description>This article goes beyond the broad “control plane / execution plane” framing and instead walks through the current implementation piece by piece—covering the bridging layer, IPC, configuration fields, the WeChat flow, rollback mechanisms, and the evidence index—to explain how QClaw organizes OpenClaw into a deliverable desktop runtime.</description>
    <link>https://blog.leeguoo.com/en/posts/qclaw-implementation-principles-20260310/</link>
    <guid>https://blog.leeguoo.com/en/posts/qclaw-implementation-principles-20260310/</guid>
    <dc:creator>Guo Li (leeguoo)</dc:creator>
    <pubDate>Wed, 11 Mar 2026 00:34:50 GMT</pubDate>
  </item>
  </channel>
</rss>