<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Richard Fu</title>
    <description>The latest articles on DEV Community by Richard Fu (@furic).</description>
    <link>https://dev.to/furic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F496204%2F16f7c90a-db34-4f71-aec8-bd491e334b4d.jpg</url>
      <title>DEV Community: Richard Fu</title>
      <link>https://dev.to/furic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/furic"/>
    <language>en</language>
    <item>
      <title>Six Months of AI Buy Signals: The Honest Numbers</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Mon, 10 Aug 2026 13:40:44 +0000</pubDate>
      <link>https://dev.to/furic/six-months-of-ai-buy-signals-the-honest-numbers-2a3a</link>
      <guid>https://dev.to/furic/six-months-of-ai-buy-signals-the-honest-numbers-2a3a</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnmzg98wr2rbfn2ehrwqz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnmzg98wr2rbfn2ehrwqz.png" alt=" " width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 4 of 4 — the results. &lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;Part 1: every safety net failed silently&lt;/a&gt; · &lt;a href="https://dev.to/furic/free-llm-api-tiers-running-three-models-for-0month-4bmp"&gt;Part 2: the free stack&lt;/a&gt; · &lt;a href="https://dev.to/furic/linkedin-api-approval-rejected-what-they-actually-require-4g1m"&gt;Part 3: publishing signals&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first three parts were about how my portfolio monitor works and how its safeguards failed. This one is the question everybody actually asks: do the &lt;strong&gt;AI buy signals&lt;/strong&gt; make money?&lt;/p&gt;

&lt;p&gt;The portfolio is up &lt;strong&gt;13.8% on cost basis&lt;/strong&gt;. Last post, three months ago, that number was 6%. I’ll keep the balances to myself — the ratios are the only part that carries information anyway, and a tool whose whole design premise is “don’t publish your position sizing” is a poor place to publish mine.&lt;/p&gt;

&lt;p&gt;Now the four reasons that number tells you much less about the AI buy signals than it looks like it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four caveats before you read anything into it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It’s return on capital deployed, not a time-weighted return.&lt;/strong&gt; I’ve been adding cash throughout, as the tool flagged gaps — that’s what it’s for. So a dollar that went in last week counts the same as one from February. You can’t line this up against “the S&amp;amp;P did X% over the same window” and call the difference alpha.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The market was up too.&lt;/strong&gt; Most of 13.8% is beta, not skill. I haven’t done the work to separate them, and I’m suspicious of anyone claiming they have on a six-month sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One portfolio is not a backtest.&lt;/strong&gt; N=1, no control, and I chose which AI buy signals to act on. The tool suggests; I still click the button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Six months is noise.&lt;/strong&gt; Any strategy can look good over one up-trending half-year.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I’d actually want to know about these AI buy signals — whether the STRONG BUY gate has predictive value — needs far more STRONG BUYs than it has produced, which is by design. &lt;a href="https://dev.to/furic/free-llm-api-tiers-running-three-models-for-0month-4bmp"&gt;The unanimity rule&lt;/a&gt; made them rarer still. Ask me in two years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part: my best trade broke my own plan
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;44% of that gain came from a single position, MSFT.&lt;/strong&gt; And MSFT was never in my target allocation.&lt;/p&gt;

&lt;p&gt;It was on the &lt;strong&gt;watch list&lt;/strong&gt; : a feature for tickers I’m interested in but haven’t committed to a target weight. On 21 June it came back a STRONG BUY at 88% confidence, Value A. I read the thesis, set a limit order where the card told me to, and it filled at &lt;strong&gt;$370.10&lt;/strong&gt;. Off-plan, discretionary, exactly the kind of decision allocation targets exist to prevent.&lt;/p&gt;

&lt;p&gt;Here’s the card, verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;MSFT&lt;/strong&gt; · STRONG BUY · Value A · 88%&lt;/p&gt;

&lt;p&gt;(Watch) Exceptional multi-indicator bottom setup: trailing P/E of 22.6 is below the historical average of 23.4 (price-level signal), 52w position of 12% (price-level signal), price -15.9% below 200MA (price-level signal). RSI of 18.6 is deeply oversold, Stochastic %K of 6.6 is deeply oversold, and %B of 0.11 is at/below the lower Bollinger Band — all momentum signals confirming. Forward P/E of 19.6 below historical avg, 34% ROE, 23.4% EPS growth, low debt, analyst target of $561 (+48% upside). Death cross and bearish MACD are risk flags, but all other indicators overwhelmingly support a bottom-forming scenario. Historical conviction has been strengthening (42% → 88% → 70%). Risk: macro headwinds (high yields, strong USD) may extend the drawdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Momentum:&lt;/strong&gt; bearish · RSI 18.6 · 50MA $412.98 (-8.1%) · 200MA $451.35 · death cross · MACD hist -5.027 · %B 0.11&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Limit order:&lt;/strong&gt; $370 — Set just below current price at ~$370, near the prior consolidation zone and round-number support; provides a small buffer given ATR-implied volatility&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Bottom signal:&lt;/strong&gt; RSI &amp;lt; 30, price below 200MA, death cross, 52w position &amp;lt; 30% — 4 of 4 bottom indicators active for a stock&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;RSI 18.6 on a mega-cap.&lt;/strong&gt; I had to re-read that. A reading that deep on the largest companies in the world is rare — it’s what you get in a capitulation, which is precisely what the reasoning says. Two days later the same ticker printed &lt;strong&gt;9.3&lt;/strong&gt; , and that card is &lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;a story of its own&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The generous reading is that the feature did its job, and I’d go further: this is not a marginal call the gate waved through. Six independent signals — three price-level, three momentum — four of four bottom indicators, forward P/E below its own historical average, and the part I care most about: &lt;strong&gt;the risks are named, not buried.&lt;/strong&gt; Death cross present. MACD bearish. Momentum bearish. Macro headwinds that may extend the drawdown. The model argued &lt;em&gt;past&lt;/em&gt; those rather than omitting them, and left me a written thesis I could audit six weeks later.&lt;/p&gt;

&lt;p&gt;That auditability is the real product. AI buy signals you can’t interrogate afterwards are just a number with a colour.&lt;/p&gt;

&lt;p&gt;The historical-conviction line is the reasoning-persistence store paying off, too. Three features compounding: the watch list surfaced a ticker I didn’t own, the bottom-fishing model caught the extreme, and the 7-day history put the reading in context.&lt;/p&gt;

&lt;p&gt;There’s also a fourth mechanism that was supposed to be involved and wasn’t. Both of these cards — the 88% I bought on and the 91% two days later — came from Claude alone. Gemini was quota-exhausted, so the cross-model agreement check never ran on either, and nothing in the email said so. The number I acted on was a single model’s opinion wearing the same badge a two-model consensus would have worn. That story is &lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;Part 1&lt;/a&gt;, and it’s the reason v1.9 exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  I followed the one instruction it gave me. That was the mistake.
&lt;/h2&gt;

&lt;p&gt;The card told me to set a limit at &lt;strong&gt;$370&lt;/strong&gt; — “just below current price at ~$370, near the prior consolidation zone and round-number support.” I did exactly that, at $370.10, and it filled.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl6m85uqh84t4it0fcghx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl6m85uqh84t4it0fcghx.jpg" alt="Richfolio MSFT STRONG BUY Card 21 Jun" width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then MSFT came back a STRONG BUY the next day, and the day after that — &lt;strong&gt;88%, 88%, 91%&lt;/strong&gt; across 21, 22 and 23 June — trading lower each time. There was no window closing on me. The signal was still there three days later, and by then the same feature was naming a different price: &lt;strong&gt;$360&lt;/strong&gt; , reasoning from the 90-day consolidation floor with the 10th percentile at ~$354. MSFT traded down to &lt;strong&gt;$349.20&lt;/strong&gt; shortly after.&lt;/p&gt;

&lt;p&gt;Put those two limit prices side by side and the flaw is hard to unsee. The second one is anchored to a &lt;em&gt;distribution&lt;/em&gt; — where the stock has actually spent the last 90 days. The first is anchored to the spot price: a small buffer below wherever MSFT happened to be trading that morning, dressed up in the vocabulary of support. Only one of them is a limit price in the sense I built the feature for. The other is “buy it now, but ten dollars less.”&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgeg8lc6c2osjtuvvq5ha.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgeg8lc6c2osjtuvvq5ha.jpg" alt="Richfolio MSFT STRONG BUY Card 23 Jun" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That cost about four percentage points on the position: 33.7% instead of the 37.5% a $360 fill would have returned. Trivial in absolute terms, and the most instructive line in this whole series — because I did everything right by the process. I read the reasoning, I agreed with it, I placed the order at the number it gave me. There was no 11pm override to blame.&lt;/p&gt;

&lt;p&gt;There is one tell, though, and it’s mine. The card said $370 and I set $370.10 — ten cents up, to be sure of the fill. I wanted the position more than I wanted the price, and I expressed that in the only digit the tool had left me.&lt;/p&gt;

&lt;p&gt;If you build AI buy signals for yourself, the lesson isn’t “trust the limit price” or “don’t.” It’s that a suggested number gets exactly as much credibility as the thing it’s anchored to, and a model will narrate a spot-price offset in the same confident register it uses for a real support level. I couldn’t have caught that from one card. It took three of them across three days, which is the argument for persisting the reasoning rather than just acting on the number at the bottom of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest reading of six months of AI buy signals
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A good thesis and a good outcome are different things.&lt;/strong&gt; Everything in that card was true on 21 June, and it would still have been true if MSFT had kept falling. “Buy the capitulation” works until the death cross the model waved past turns out to have meant something. I got the outcome; I can’t claim the process is validated by it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;N=1.&lt;/strong&gt; If this trade had gone the other way, the same feature and the same reasoning would have produced the opposite anecdote — and I probably wouldn’t be writing a section about it. That asymmetry is worth naming, because it’s how every tool’s success story gets written.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The split is unflattering to the plan.&lt;/strong&gt; That single off-plan position returned &lt;strong&gt;33.7%&lt;/strong&gt;. The other fourteen — only nine of which are the actual target allocation — returned the remaining 56% of the gains on a much larger base, about &lt;strong&gt;9.4%&lt;/strong&gt;. My least disciplined decision beat my whole system by 24 points, which is either a lesson about signals or a lesson about small samples, and I genuinely don’t know which.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;44% of gains in one unplanned position is a concentration risk&lt;/strong&gt; , not a result. I’d flag it red in anyone else’s portfolio.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I’m recording it, not celebrating it — one lucky trade is not evidence about AI buy signals in general. Ask me again after the tenth watch-list buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug this exposed
&lt;/h2&gt;

&lt;p&gt;There’s a design gap underneath the anecdote, and it’s more useful than the anecdote.&lt;/p&gt;

&lt;p&gt;Buying a watch-list ticker gives it nowhere to go. I took MSFT off &lt;code&gt;watching&lt;/code&gt;, but never gave it a target allocation — so it became what the codebase calls a &lt;strong&gt;held-only ticker&lt;/strong&gt; : held, no target, not watched. Its allocation gap is permanently negative against an implied 0% target, which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the daily brief rendered it as HOLD/WAIT noise (“MSFT is N% overweight vs. a 0% target”), and&lt;/li&gt;
&lt;li&gt;the weekly rebalancing report handed it a standing &lt;strong&gt;TRIM&lt;/strong&gt; , every week, forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My best-performing position was being told to sell itself on a schedule, because I bought it through a path with no final step. The watch list can surface a ticker and hand me a signal, but there is no &lt;strong&gt;promote to portfolio&lt;/strong&gt; — no moment where a watch-list buy becomes a target allocation with a real percentage.&lt;/p&gt;

&lt;p&gt;I’d written &lt;a href="https://github.com/furic/richfolio/blob/main/specs/2026-06-29-held-only-tickers-design.md" rel="noopener noreferrer"&gt;a design for half of it&lt;/a&gt; back in June, before MSFT gave me a reason to care — the standing TRIM was already visible on other holdings, just not on one I felt anything about. It shipped in v1.10: held-only tickers route to a separate &lt;code&gt;untrackedItems&lt;/code&gt; array, so they stop generating buy and trim recommendations while still counting toward portfolio value, beta, dividends and ETF overlap. The weekly report now lists them neutrally — value and current %, no action verb.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5np56et1xep6asj83tni.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5np56et1xep6asj83tni.jpg" width="800" height="1339"&gt;&lt;/a&gt; &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96lqzywdv5c06u4iy281.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96lqzywdv5c06u4iy281.jpg" width="800" height="845"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The nice side effect is token cost. Every consumer that feeds the AI iterates the tracked-items array, so removing them from it took them out of the prompt entirely. Four tickers I was paying to have opinions generated about, opinions I could never act on.&lt;/p&gt;

&lt;p&gt;That still doesn’t fix the real gap. Silencing the TRIM is not the same as deciding what MSFT’s target weight should be, and that decision is mine, not the tool’s. The most a tool can honestly do is stop pretending a deliberate off-plan holding is a rebalancing error — and then leave the question with me.&lt;/p&gt;

&lt;p&gt;One more thing I only noticed while implementing it. Two other holdings, MU and MRVL, sit in both &lt;code&gt;currentHoldings&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;watching&lt;/code&gt;, because I bought them off watch-list signals and never cleaned up. That combination accidentally did the right thing all along: watched tickers were already excluded from the allocation table, so those two never got the standing TRIM the others did. I’d been running the fix by hand, on two tickers, without knowing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that leaves the experiment
&lt;/h2&gt;

&lt;p&gt;Six months in, the honest summary is that I can’t tell you whether AI buy signals beat buying the index, and neither can anyone else with one portfolio and one up-trending half-year. What I can tell you is what changed about &lt;em&gt;using&lt;/em&gt; it: I read the briefs again. Before the frozen-data guard, a Telegram buzz meant “maybe something happened,” and I’d started ignoring them.&lt;/p&gt;

&lt;p&gt;That’s the real failure mode for a tool like this — not being wrong, but being noisy enough that you stop reading it. Fixing the noise was worth more than any individual recommendation — AI buy signals you’ve stopped opening are worth exactly nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still looking for alpha testers
&lt;/h2&gt;

&lt;p&gt;Richfolio is open source and free to run: &lt;a href="https://github.com/furic/richfolio" rel="noopener noreferrer"&gt;github.com/furic/richfolio&lt;/a&gt;, docs at &lt;a href="https://furic.github.io/richfolio" rel="noopener noreferrer"&gt;furic.github.io/richfolio&lt;/a&gt;. Fork it, add your allocations to a config file, set a Gemini key, and GitHub Actions does the rest.&lt;/p&gt;

&lt;p&gt;Most useful to me right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-US portfolios&lt;/strong&gt; , especially LSE/JSE tickers — the sub-unit currency guard in &lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;Part 1&lt;/a&gt; came out of exactly one such bug, and I doubt it’s the last&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Larger universes&lt;/strong&gt; (20+ tickers) — both truncation bugs this cycle came from a big universe, and mine isn’t big&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone running two AI providers&lt;/strong&gt; — Gemini + Mistral is now free on both sides, so this costs nothing to try. I’d like to know how often two models actually disagree on portfolios that aren’t mine, because the unanimity rule is only worth its complexity if that number is meaningfully above zero&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open an issue on the repo or DM me.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Nothing here is financial advice. It’s a description of a tool I built for myself and the mistakes I made using it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/six-months-ai-buy-signals-results/" rel="noopener noreferrer"&gt;Six Months of AI Buy Signals: The Honest Numbers&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>finance</category>
      <category>webdev</category>
      <category>aibuysignals</category>
      <category>rsi</category>
    </item>
    <item>
      <title>LinkedIn API Approval Rejected: What They Actually Require</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sun, 09 Aug 2026 13:38:21 +0000</pubDate>
      <link>https://dev.to/furic/linkedin-api-approval-rejected-what-they-actually-require-4g1m</link>
      <guid>https://dev.to/furic/linkedin-api-approval-rejected-what-they-actually-require-4g1m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnmxcm43ca4jvh6i17zy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnmxcm43ca4jvh6i17zy.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 3 of 4 — publishing. &lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;Part 1: every safety net failed silently&lt;/a&gt; · &lt;a href="https://dev.to/furic/free-llm-api-tiers-running-three-models-for-0month-4bmp"&gt;Part 2: the free stack&lt;/a&gt; · &lt;a href="https://www.richardfu.net/six-months-ai-buy-signals-results/" rel="noopener noreferrer"&gt;Part 4: what it returned&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built social posting into my portfolio tool so it could publish its own buy signals — four platforms: X, Facebook Page, Threads, LinkedIn Page. The code took hours per platform. Getting &lt;em&gt;permission&lt;/em&gt; — the LinkedIn API approval in particular — took weeks, and two of the four never sent a single request.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;LinkedIn API approval&lt;/strong&gt; is the one worth writing down, because the rejection reason isn’t in the docs and I couldn’t find a first-hand account of it anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two of the four have never posted anything
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Blocked by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Facebook Page&lt;/td&gt;
&lt;td&gt;live&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threads&lt;/td&gt;
&lt;td&gt;live&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;dormant&lt;/td&gt;
&lt;td&gt;no free tier since Feb 2026; ~$0.015/post, more with a link&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn Page&lt;/td&gt;
&lt;td&gt;dormant&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;identity vetting failed&lt;/strong&gt; — appeal in flight&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6f5cp2nfpwd5thab5y4d.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6f5cp2nfpwd5thab5y4d.jpg" width="800" height="707"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;X&lt;/strong&gt; is a straightforward economic decision. The API works, the OAuth 1.0a signing works, and I’m not paying per post to broadcast signals nobody asked for. My &lt;code&gt;includeLinkInX&lt;/code&gt; flag defaults to &lt;code&gt;false&lt;/code&gt; for the same reason — adding a link raises the per-post cost by more than 10×. If I ever turn it on, that flag is why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LinkedIn&lt;/strong&gt; is the interesting failure, and the rest of this post is about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the LinkedIn API approval process actually checks
&lt;/h2&gt;

&lt;p&gt;Posting to a LinkedIn Page as an app requires the &lt;code&gt;w_organization_social&lt;/code&gt; permission, which is gated behind their “Share on LinkedIn” product review — that review &lt;em&gt;is&lt;/em&gt; the LinkedIn API approval. The API itself is free. So I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;created the app and associated it with a Page&lt;/li&gt;
&lt;li&gt;wrote a privacy policy page specifically for this review (and Meta’s)&lt;/li&gt;
&lt;li&gt;documented what the app posts, how often, and what data it touches&lt;/li&gt;
&lt;li&gt;submitted, and waited &lt;strong&gt;two weeks&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LinkedIn API approval rejection, when it arrived, mentioned none of that. Not the code, not the privacy policy, not the posting behaviour, not the data handling. The developer portal gave one reason:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1v267vjscq976giwjums.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1v267vjscq976giwjums.png" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Identity vetting failed.&lt;/strong&gt; We were unable to verify that your organization is a legally registered, active entity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix it suggested was a valid link to an organizational website. I had given them the project’s GitHub Pages documentation site. &lt;strong&gt;Not accepted.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those two things are worth separating, because I ran them together for weeks. The &lt;em&gt;requirement&lt;/em&gt; is proof that a legally registered entity exists. The &lt;em&gt;website&lt;/em&gt; is only the evidence they ask for by default, because for most applicants it’s the fastest thing for a reviewer to check. A docs site on &lt;code&gt;github.io&lt;/code&gt; demonstrates that a project exists. It demonstrates nothing about registration.&lt;/p&gt;

&lt;p&gt;The signal was there in the permission name: &lt;code&gt;w_organization_social&lt;/code&gt;. The product is built for organizations posting as themselves, and a reviewer asking “is there a registered entity behind this” is doing the job correctly.&lt;/p&gt;

&lt;p&gt;What I got wrong is that I read the question as being about &lt;em&gt;size&lt;/em&gt;, and answered no — there’s a repository and one user. It isn’t about size. I’ve held an active sole-trader ABN, an Australian business registration listed on a public government register, since months before I created the app. I never gave it to them. I gave them a documentation site and expected substance to stand in for registration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options, and the one I missed
&lt;/h2&gt;

&lt;p&gt;Two ways forward, as I saw it at the time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stand up a real site.&lt;/strong&gt; Buy &lt;code&gt;richfolio.app&lt;/code&gt;, build a landing page, resubmit, wait another two weeks, probably pass.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leave LinkedIn dormant.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I picked the second, and I’d still pick it over the first. Buying a domain and building a marketing site whose only purpose is to unlock a posting integration — for a tool with one user — is the tail wagging the dog. If the project ever justifies a site on its own merits, I’ll do it then and the LinkedIn API approval becomes a free side effect rather than the reason.&lt;/p&gt;

&lt;p&gt;What I’d missed is that the rejection isn’t the end of it. The denial notice links to a &lt;strong&gt;vetting appeal&lt;/strong&gt; form, and the appeal asks for the thing my original application never contained: documentation that a legally registered entity exists. Not a website. A registration someone can look up.&lt;/p&gt;

&lt;p&gt;So there’s a third option, and it’s the one I’m taking — appeal with the ABN and let a reviewer check it against the public register. That’s in flight as I publish this, and I don’t know yet how it lands. If a website turns out to be required in practice regardless of what the appeal form asks for, the conclusion I started with stands. If it isn’t, then two weeks of this was me answering a question nobody had asked.&lt;/p&gt;

&lt;p&gt;That’s not a complaint about LinkedIn either way. The bar is a fair rule, enforced consistently. I just spent two weeks failing to notice which rule it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The estimate I got wrong
&lt;/h2&gt;

&lt;p&gt;I planned all four integrations as engineering work, and the engineering was genuinely small — each platform is roughly fifty lines against a shared content builder, gated on its own credentials, wrapped in its own try/catch so one dead token can’t block the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Platform access was the real cost: weeks of calendar time, and two of four failed on criteria no amount of code could satisfy.&lt;/strong&gt; For X the blocker is a price. For LinkedIn it was an identity check I answered with the wrong document.&lt;/p&gt;

&lt;p&gt;If you’re scoping “post to social” for a personal project, the useful advice is: &lt;strong&gt;budget the approvals, not the integrations&lt;/strong&gt; — and expect a nonzero share to simply not happen. Two of mine didn’t. I’d plan for that ratio next time rather than treating it as bad luck.&lt;/p&gt;

&lt;p&gt;There’s one more thing worth flagging, which connects to &lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;Part 1&lt;/a&gt;: both dormant integrations are &lt;em&gt;in&lt;/em&gt; the codebase, tested, and permanently no-op because their credentials are unset. That’s the correct design — every platform skips silently when unconfigured, which is exactly what you want when a token expires overnight. But it does mean “shipped in the repo” and “running in production” are two different claims, and the code can’t tell you which one it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I’d have written even if all four worked
&lt;/h2&gt;

&lt;p&gt;The interesting engineering problem was never the four APIs. It’s that every recommendation object in my pipeline carries private data — suggested buy amounts, allocation gaps, current and target percentages, ETF overlap discounts — and I’d rather not publish my position sizing to the internet.&lt;/p&gt;

&lt;p&gt;The approach: one chokepoint that projects onto an explicit allowlist, rather than N call sites each remembering to omit the right fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="cm"&gt;/**
 * Privacy chokepoint. Filters to publishable buy signals and projects each
 * onto the generic allowlist — nothing else from the source object survives.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildSignalLines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SignalSource&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;SignalSource&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRONG BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;tickerFullName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tickerFullName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sanitizeReason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;valueRating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valueRating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;analysisUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;analysisUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* action rank, then confidence */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Explicit construction, not &lt;code&gt;delete&lt;/code&gt; or destructured rest. A new private field added upstream can’t leak by default — it simply isn’t in the projection. And that module deliberately imports no config and does no network calls, so it’s unit-testable with no config file present. There’s a test asserting private values never appear in the output.&lt;/p&gt;

&lt;p&gt;The harder half is prose. The reason text is written by the AI for &lt;em&gt;me&lt;/em&gt;, so it says things like “underweight by 2.3%, fill ~$4,180 after ETF overlap discount” (figures illustrative — that sentence is exactly what must never reach a feed). Field-level allowlisting doesn’t help when the leak is inside a sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRIVATE_SENTENCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\$\s?\d{1,3}(?:&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\d{3})&lt;/span&gt;&lt;span class="sr"&gt;+|allocation gap|overlap discount|after etf overlap|under-&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;weight|over-&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;weight|portfolio &lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;value|total&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;|position siz&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Sentence-level filtering: split on sentence boundaries, drop any sentence that trips the pattern, keep the rest. Then a backstop regex strips any comma-grouped dollar figure that survived in a kept sentence.&lt;/p&gt;

&lt;p&gt;The comma-grouping detail is deliberate. &lt;code&gt;$4,180&lt;/code&gt; is a position size; &lt;code&gt;$205&lt;/code&gt; is a share price, which is public information and useful in a post. Requiring comma-grouped thousands separates the two without needing to know which is which semantically.&lt;/p&gt;

&lt;p&gt;The same pass strips internal guard annotations and any sentence mentioning the watch list — because the subtler leak isn’t dollars. If posts labelled tickers as “portfolio” versus “watching”, I’d be broadcasting exactly what I own. So portfolio and watch-list signals merge into one undifferentiated list. &lt;strong&gt;Which tickers I hold is not derivable from the output.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That was the design constraint I cared about, and it’s the half that works. The half that didn’t work was asking four companies for permission.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; &lt;a href="https://www.richardfu.net/six-months-ai-buy-signals-results/" rel="noopener noreferrer"&gt;Part 4 — what six months of AI buy signals actually returned&lt;/a&gt;, including the trade that beat my whole system and the reason that’s uncomfortable.&lt;/p&gt;

&lt;p&gt;Richfolio is open source: &lt;a href="https://github.com/furic/richfolio" rel="noopener noreferrer"&gt;github.com/furic/richfolio&lt;/a&gt; · &lt;a href="https://furic.github.io/richfolio" rel="noopener noreferrer"&gt;docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/linkedin-api-approval-rejected-organizational-website/" rel="noopener noreferrer"&gt;LinkedIn API Approval Rejected: What They Actually Require&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>finance</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>linkedinapiapproval</category>
    </item>
    <item>
      <title>Free LLM API Tiers: Running Three Models for $0/month</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 08 Aug 2026 14:24:02 +0000</pubDate>
      <link>https://dev.to/furic/free-llm-api-tiers-running-three-models-for-0month-4bmp</link>
      <guid>https://dev.to/furic/free-llm-api-tiers-running-three-models-for-0month-4bmp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazokzvresr3w1lbctuxk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazokzvresr3w1lbctuxk.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 2 of 4 — the stack. &lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;Part 1: every safety net failed silently&lt;/a&gt; · &lt;a href="https://www.richardfu.net/linkedin-api-approval-rejected-organizational-website/" rel="noopener noreferrer"&gt;Part 3: publishing signals&lt;/a&gt; · &lt;a href="https://www.richardfu.net/six-months-ai-buy-signals-results/" rel="noopener noreferrer"&gt;Part 4: what it returned&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;Part 1&lt;/a&gt; was about the safeguards in my portfolio monitoring tool and the six ways they silently stopped running. This one is the boring, useful half: what it actually runs on, why every component is a &lt;strong&gt;free LLM API&lt;/strong&gt; or free tier, and which of those free tiers turned out to be load-bearing in a way I hadn’t thought about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free LLM API stack — back to $0/month, the second time around
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Free tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prices, fundamentals, technicals, ETF holdings&lt;/td&gt;
&lt;td&gt;Yahoo Finance (&lt;code&gt;yahoo-finance2&lt;/code&gt; v3)&lt;/td&gt;
&lt;td&gt;unofficial, unmetered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headlines&lt;/td&gt;
&lt;td&gt;NewsAPI.org&lt;/td&gt;
&lt;td&gt;100 req/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI analysis&lt;/td&gt;
&lt;td&gt;Google Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;~20 req/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI analysis (second opinion)&lt;/td&gt;
&lt;td&gt;Mistral Large&lt;/td&gt;
&lt;td&gt;~1B tokens/month (Experiment tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI analysis (third opinion)&lt;/td&gt;
&lt;td&gt;Anthropic Claude Sonnet 4.6&lt;/td&gt;
&lt;td&gt;Claude Pro subscription — no per-token cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;3,000/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram&lt;/td&gt;
&lt;td&gt;Bot API&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduler&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;td&gt;free (public repo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs site&lt;/td&gt;
&lt;td&gt;GitHub Pages&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That second-opinion row spent most of this window saying “Anthropic Claude — pay-per-use”, because Claude has no free LLM API tier. Then the balance ran out, and I got a useful lesson in what a free tier is actually load-bearing for: &lt;strong&gt;not cost, continuity.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A free LLM API tier is a promise about next month; pay-per-use inside a zero-maintenance system is a scheduled outage with an unknown date. Mine landed silently. Because the orchestrator degrades gracefully when a provider fails, every brief kept arriving — just quietly weaker — and it took me days to notice. The failure mode wasn’t the bill; it was that nothing broke loudly enough to tell me.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not DeepSeek
&lt;/h3&gt;

&lt;p&gt;DeepSeek was the obvious replacement and I skipped it. Its free LLM API allowance is a one-time 5M-token grant valid for 30 days, not a perpetual tier. After that it’s pay-as-you-go — very cheap, roughly $1/month at my volume after the ~75% price cut in May 2026, but that’s the same trap with a longer fuse. I’d be back here in a month, and the whole point of this project is that I don’t want to maintain it.&lt;/p&gt;

&lt;p&gt;If you’re happy paying a dollar rather than managing a free LLM API allowance, DeepSeek is a genuinely good answer. It just isn’t a free one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not Groq or Cerebras
&lt;/h3&gt;

&lt;p&gt;Both offer generous permanent free tiers — Groq at ~30 requests/minute serving Llama 3.3 70B, Cerebras at 1M tokens/day — and both are far faster than what I’m using. I still skipped them, for a reason that has nothing to do with their free LLM API terms.&lt;/p&gt;

&lt;p&gt;Free LLM API generosity was never the deciding factor. My system caps a STRONG BUY unless &lt;em&gt;every&lt;/em&gt; model independently agrees. That means an extra model only adds information when its disagreement reflects the &lt;strong&gt;data&lt;/strong&gt; rather than the model being weaker. Pair a strong model with a noticeably weaker one and you don’t get a cross-check, you get a random gate: the weaker model dissents on cases it simply handled worse, and your best signals get suppressed for the wrong reason.&lt;/p&gt;

&lt;p&gt;A 70B open-weight model against Gemini 2.5 Flash would have been a downgrade dressed as an upgrade. Its dissent would mostly tell me about the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Mistral
&lt;/h3&gt;

&lt;p&gt;Mistral Large sits at comparable capability to Gemini Flash on this kind of structured reasoning, on a genuinely different model lineage, with a permanent Experiment tier of roughly 1B tokens/month against my actual usage of about 7M. That combination — capable, independent, permanently free — is the only free LLM API pairing where a disagreement between the two is worth reading.&lt;/p&gt;

&lt;p&gt;It also has proper &lt;strong&gt;strict structured output&lt;/strong&gt; , which matters more than it sounds. More on that below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claude came back, and not through the API
&lt;/h3&gt;

&lt;p&gt;Claude still has no free LLM API tier — that part of this post hasn’t aged. What it has is a route I’d been ignoring: if you already pay for Claude Pro, &lt;code&gt;claude setup-token&lt;/code&gt; mints a long-lived credential that authenticates against your &lt;strong&gt;subscription allocation&lt;/strong&gt; instead of API credits. Same models, no per-token bill.&lt;/p&gt;

&lt;p&gt;So the second opinion became a third. That matters more than “one more model” — the unanimity rule below is only as strong as the number of genuinely independent votes behind it, and going from two to three changes what a dissent means.&lt;/p&gt;

&lt;p&gt;The catch is that this isn’t the Messages API. It runs through the Claude Agent SDK, which spawns a Claude Code subprocess per call, and that has a shape of its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A full daily run went from ~2 minutes to ~20.&lt;/strong&gt; Irrelevant against a cron that fires hours apart. Genuinely annoying when you’re iterating on one ticker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It enforces its own 32,000 output-token ceiling&lt;/strong&gt; , separate from the API’s &lt;code&gt;max_tokens&lt;/code&gt;. A 21-ticker decision stage sailed straight past it, Claude threw, and the brief degraded to two providers. I caught that in one run instead of a week, purely because the degradation badge from &lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;Part 1&lt;/a&gt; was already there to say so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It emits noticeably more output than the API path for identical input.&lt;/strong&gt; The API path finishes the same work inside 16K tokens. Whatever the harness wraps around the call, you pay for it out of the same allocation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is a reason to skip it. But “free” here buys you a slower, chattier transport — not the same call with the bill removed. Point a coding-agent harness at a batch inference job and that’s the trade you’re making.&lt;/p&gt;

&lt;p&gt;One constraint hasn’t changed across any of this: it was never really about money, it’s about &lt;strong&gt;request budgets&lt;/strong&gt;. When I started, Gemini’s free tier was 250 calls/day. It’s now 20 for 2.5 Flash — and two-stage prompting across six scheduled runs needs 12 before the news filter gets a look in. A free tier is a promise about next month, and this one was quietly revised downward mid-project. Every design decision downstream is shaped by that ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database is a cache, and that’s fine
&lt;/h2&gt;

&lt;p&gt;There’s a &lt;code&gt;state/&lt;/code&gt; directory holding two files: the morning baseline that intraday runs compare against, and the 7-day rolling reasoning history the prompts read. Both need to survive between runs of a workflow whose machine is destroyed every time it finishes.&lt;/p&gt;

&lt;p&gt;The whole persistence layer is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v5&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state/&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;intraday-state-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;intraday-state-&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;run_id&lt;/code&gt; in the key is the part worth stealing. Cache entries in GitHub Actions are &lt;strong&gt;immutable&lt;/strong&gt; — you cannot overwrite a key once it exists. A stable key like &lt;code&gt;intraday-state-v1&lt;/code&gt; looks obviously right and then silently stops saving after the very first run, because every subsequent save is a no-op against an existing key. Making the key unique per run means the save always succeeds, and &lt;code&gt;restore-keys&lt;/code&gt; prefix-matches to pull back the &lt;strong&gt;most recent&lt;/strong&gt; entry. Each run reads the previous run’s state and writes a new one: a rolling single-writer chain, no database, no S3 bucket, no credentials.&lt;/p&gt;

&lt;p&gt;What makes this acceptable rather than reckless is that &lt;strong&gt;nothing important lives only there.&lt;/strong&gt; It’s a cache, and GitHub will evict it — 7 days without access, or LRU once the repo passes 10 GB. So every consumer treats absence as normal, not exceptional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ageHours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ageHours&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Baseline is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ageHours&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;h old (max 18h) — skipping comparison`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;A missing baseline means the intraday run exits quietly instead of comparing against stale numbers. A missing reasoning history means the prompt omits its HISTORICAL CONTEXT section — the analysis is slightly worse that day and nothing breaks. When the history schema changed in v1.7 I discarded the old entries rather than writing a migration, on the grounds that seven days of conviction snapshots isn’t precious data. That’s only defensible because the storage was never load-bearing.&lt;/p&gt;

&lt;p&gt;The trap I did fall into: for months I assumed my &lt;strong&gt;local&lt;/strong&gt; &lt;code&gt;state/&lt;/code&gt; reflected what production was doing. It doesn’t — they’re two completely separate lineages. My working copy is only written by local runs, so it had been frozen at 23 June while Actions quietly maintained its own chain in the cache. When I went looking for the run that produced a particular signal, the file on my disk had nothing to do with it. Worth knowing before you try to debug a scheduled job by reading your own checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the AI layer pluggable
&lt;/h2&gt;

&lt;p&gt;v1.6 was Gemini-only, and the Gemini SDK was threaded through the analysis module. Adding a second provider meant first removing the first one from the plumbing.&lt;/p&gt;

&lt;p&gt;What came out is a provider interface and a registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
src/providers/
├── types.ts # AIProvider interface + canonical AIBuyRecommendation
├── prompts.ts # SDK-agnostic prompt builders
├── schemas.ts # shared JSON Schemas + strictify() for strict-mode dialects
├── gemini.ts # GeminiProvider (@google/genai)
├── claude.ts # ClaudeProvider (@anthropic-ai/sdk, tool-use)
├── mistral.ts # MistralProvider (native fetch, strict json_schema)
└── index.ts # buildActiveProviders() — reads env, returns what's configured

src/aiOrchestrator.ts # runs active providers concurrently, applies guards
src/aiAggregation.ts # consensus action, averaging, the unanimity rule

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

&lt;/div&gt;



&lt;p&gt;The part that mattered was pulling the &lt;strong&gt;prompt builders out of the SDK calls&lt;/strong&gt;. Every provider gets byte-identical prompts; only the transport differs — Gemini’s &lt;code&gt;responseSchema&lt;/code&gt;, Claude’s tool-use, Mistral’s &lt;code&gt;json_schema&lt;/code&gt; with &lt;code&gt;strict: true&lt;/code&gt;. Adding a provider really is about fifty lines, and the guard pipeline, reasoning history, degradation policy and renderers all pick it up for free. I know it’s fifty lines because I did it twice: Claude in v1.7, Mistral in v1.10, the second needing no changes to anything upstream.&lt;/p&gt;

&lt;p&gt;Behaviour is keyed off how many keys you set. One key → single-provider mode. Two or more → multi-model mode auto-engages, providers run concurrently, and every email and Telegram message shows a per-AI breakdown under each consensus call.&lt;/p&gt;

&lt;p&gt;One practical note on picking a free LLM API for this kind of work: adding Mistral needed no SDK at all. Its endpoint is OpenAI-compatible, so it’s native &lt;code&gt;fetch&lt;/code&gt; and zero new dependencies — the same approach the social posting module takes for four platforms.&lt;/p&gt;

&lt;h3&gt;
  
  
  One refactor worth the effort
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;schemas.ts&lt;/code&gt;. The two-stage output contract started life inside &lt;code&gt;claude.ts&lt;/code&gt;, then got copied into &lt;code&gt;detailedAnalysis.ts&lt;/code&gt;, and adding a third provider would have made three hand-maintained copies of the same JSON Schema.&lt;/p&gt;

&lt;p&gt;They’re one module now, with &lt;code&gt;strictify()&lt;/code&gt; &lt;em&gt;deriving&lt;/em&gt; the strict-mode variant rather than a second copy maintained by hand. Strict dialects — Mistral’s &lt;code&gt;json_schema&lt;/code&gt; with &lt;code&gt;strict: true&lt;/code&gt;, OpenAI’s structured outputs — require every object to declare &lt;code&gt;additionalProperties: false&lt;/code&gt; and list all properties in &lt;code&gt;required&lt;/code&gt;. That’s mechanical, so derive it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;strictify&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* walk the tree, add what strict mode demands */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Two providers asking for subtly different fields would mean the same portfolio produces structurally different recommendations depending on which model answered. That’s a bug that would have been invisible in exactly the way &lt;a href="https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2"&gt;Part 1&lt;/a&gt; is about — so it’s worth spending a module to make it impossible.&lt;/p&gt;

&lt;p&gt;Worth knowing if you’re evaluating a free LLM API for structured work: &lt;strong&gt;“supports JSON” and “constrains decoding to a schema” are different guarantees.&lt;/strong&gt; JSON mode gets you parseable output. Strict schema mode gets you output with the fields you asked for. The second one removes an entire category of repair code, and not every provider has it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unanimity rule
&lt;/h2&gt;

&lt;p&gt;Consensus is mode-of-votes with a confidence-sum tiebreaker, and if that still ties, it falls to whichever action is more conservative. Under-recommend rather than over-recommend.&lt;/p&gt;

&lt;p&gt;Then one extra rule on top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// Unanimity rule for STRONG BUY&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;consensus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRONG BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allStrongBuy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRONG BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allStrongBuy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;consensus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Four lines, and they’re the whole point of running more than one model. STRONG BUY here is supposed to be rare and high-conviction — gated on a ≥2% allocation gap, ≥80% base confidence, two or more entry signals including a price-level one, and a maximum of two live at any time. Averaging providers’ confidence would have quietly softened that: an 88% and a 74% average to 81%, which clears a bar neither model individually agreed on. At three providers the rule bites harder — one dissenter out of three still caps the whole thing.&lt;/p&gt;

&lt;p&gt;So a dissent caps the consensus at BUY. If one model thinks a setup is exceptional and the other doesn’t, that’s not an exceptional setup — that’s a disagreement, and it gets displayed as one (&lt;code&gt;unanimous&lt;/code&gt; / &lt;code&gt;majority&lt;/code&gt; / &lt;code&gt;split&lt;/code&gt; badge).&lt;/p&gt;

&lt;p&gt;The one place dissent survives is the detailed analysis page. If any provider voted STRONG BUY, the ticker still qualifies, and the page is generated from that provider’s thesis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasStrongBuyVote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AIBuyRecommendation&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRONG BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRONG BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The dissenting recommendation is frequently the most interesting thing in the brief. Capping the headline action doesn’t mean throwing away the argument.&lt;/p&gt;

&lt;p&gt;What I still don’t know is how &lt;em&gt;often&lt;/em&gt; capable models disagree on the same portfolio. On mine the sample is too small to say, which makes the unanimity rule a well-reasoned bet rather than a validated one. If you run it, that’s the number I’d most like to hear about.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; &lt;a href="https://www.richardfu.net/linkedin-api-approval-rejected-organizational-website/" rel="noopener noreferrer"&gt;Part 3 — publishing signals publicly without publishing my portfolio&lt;/a&gt;, where two of four integrations never sent a single request.&lt;/p&gt;

&lt;p&gt;Richfolio is open source: &lt;a href="https://github.com/furic/richfolio" rel="noopener noreferrer"&gt;github.com/furic/richfolio&lt;/a&gt; · &lt;a href="https://furic.github.io/richfolio" rel="noopener noreferrer"&gt;docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/free-llm-api-three-model-stack/" rel="noopener noreferrer"&gt;Free LLM API Tiers: Running Three Models for $0/month&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>freellmapi</category>
    </item>
    <item>
      <title>AI Portfolio Monitoring: Every Safety Net Failed Silently</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:02:42 +0000</pubDate>
      <link>https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2</link>
      <guid>https://dev.to/furic/ai-portfolio-monitoring-every-safety-net-failed-silently-2fo2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxtnf0u1zh2luq29utq8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxtnf0u1zh2luq29utq8.png" alt=" " width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 1 of 4 — the safeguards. &lt;a href="https://www.richardfu.net/free-llm-api-three-model-stack/" rel="noopener noreferrer"&gt;Part 2: the free stack&lt;/a&gt; · &lt;a href="https://www.richardfu.net/linkedin-api-approval-rejected-organizational-website/" rel="noopener noreferrer"&gt;Part 3: publishing signals&lt;/a&gt; · &lt;a href="https://www.richardfu.net/six-months-ai-buy-signals-results/" rel="noopener noreferrer"&gt;Part 4: what it returned&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three months ago I wrote up &lt;a href="https://dev.to/furic/richfolio-three-months-in-ai-architecture-in-production-13il"&gt;how Richfolio went from v1.0 to v1.6&lt;/a&gt; — the guard pipeline, the two-stage Think/Plan prompt, the reasoning persistence, all of it borrowed from patterns I liked in OpenAlice. Richfolio is my &lt;strong&gt;AI portfolio monitoring&lt;/strong&gt; system: a cron job that reads my holdings, asks two language models what to buy, and emails me a brief.&lt;/p&gt;

&lt;p&gt;Four releases since then. &lt;a href="https://github.com/furic/richfolio/releases/tag/v1.7.0" rel="noopener noreferrer"&gt;v1.7&lt;/a&gt; made the AI layer pluggable and put two providers side by side. &lt;a href="https://github.com/furic/richfolio/releases/tag/v1.8.0" rel="noopener noreferrer"&gt;v1.8&lt;/a&gt; taught it to publish, to watch tickers I don’t own, and to stop lying to me about prices. &lt;a href="https://github.com/furic/richfolio/releases/tag/v1.9.0" rel="noopener noreferrer"&gt;v1.9&lt;/a&gt; and &lt;a href="https://github.com/furic/richfolio/releases/tag/v1.10.0" rel="noopener noreferrer"&gt;v1.10&lt;/a&gt; are the ones I didn’t plan — they exist entirely because of what writing this post turned up.&lt;/p&gt;

&lt;p&gt;Those are the features. They’re not what I learned.&lt;/p&gt;

&lt;p&gt;What I learned is that I spent six releases building safeguards — a price-move deadband, a unanimity rule, a guard pipeline, a fail-open policy for missing data — and then discovered, one at a time, that &lt;strong&gt;a safeguard which isn’t running is indistinguishable from a safeguard that ran and approved.&lt;/strong&gt; Same alert in my inbox. Same clean number on the card. Same plausible file on my disk. Same green test suite. Six separate instances in one release window, and I found every one by accident — three of them while drafting this post, which is the part that should worry me most.&lt;/p&gt;

&lt;p&gt;That’s this post. The features are how I got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this AI portfolio monitoring system does now
&lt;/h2&gt;

&lt;p&gt;Still one Node.js + TypeScript pipeline on GitHub Actions cron. No server, no dashboard, no database — just a &lt;code&gt;state/&lt;/code&gt; directory cached between runs. &lt;a href="https://www.richardfu.net/free-llm-api-three-model-stack/" rel="noopener noreferrer"&gt;Part 2 covers the stack and architecture&lt;/a&gt; if that’s the interesting half for you.&lt;/p&gt;

&lt;p&gt;Four modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Daily&lt;/strong&gt; (8am AEST) — allocation gaps, buy signals, news, the full brief&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intraday&lt;/strong&gt; (4× on weekdays) — re-runs the analysis, alerts only when a recommendation materially changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weekly&lt;/strong&gt; — rebalancing report&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh&lt;/strong&gt; (&lt;code&gt;npm run refresh -- SMH&lt;/code&gt;) — re-analyze one ticker on demand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Output goes to email and Telegram, and optionally to social — &lt;a href="https://www.richardfu.net/linkedin-api-approval-rejected-organizational-website/" rel="noopener noreferrer"&gt;which turned out to be a story about approvals rather than code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Everything below is about the intraday mode, because that’s where an AI portfolio monitoring tool earns or destroys your trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case study: the day my AI portfolio monitoring alerts started whipsawing
&lt;/h2&gt;

&lt;p&gt;This is the bug I’d share if I could only share one.&lt;/p&gt;

&lt;p&gt;Late July, my Telegram lit up twice in ninety minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
4:32pm VOO BUY → STRONG BUY 64% → 87% $670.63 → $673.20 (+0.38%)
6:05pm VOO STRONG BUY → BUY 87% → 76% $673.20 → $672.00 (−0.18%)

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

&lt;/div&gt;



&lt;p&gt;My first instinct was that the AI was being flaky. It wasn’t. I pulled both runs’ technicals and they were &lt;strong&gt;byte-identical&lt;/strong&gt; — same RSI, same MACD histogram, same Bollinger %B, to the last decimal. A 23-point confidence swing and back, on a ticker that had moved half a percent.&lt;/p&gt;

&lt;p&gt;Here’s why. Intraday technicals are computed from Yahoo &lt;strong&gt;daily&lt;/strong&gt; candles, and a daily candle only updates at the US close. My four intraday crons were at 10am / 12pm / 2pm / 4pm AEST — every single one firing while the US market was shut. So on all four runs, the indicators feeding the AI were the same frozen numbers from the last US close.&lt;/p&gt;

&lt;p&gt;Which means an action flip between two of those runs contained &lt;strong&gt;zero new information&lt;/strong&gt;. It was a provider’s confidence drifting across the 80% line by a couple of points — scoring noise on identical inputs — and my alert logic was faithfully paging me about it.&lt;/p&gt;

&lt;p&gt;Two fixes, one tactical and one structural.&lt;/p&gt;

&lt;p&gt;The tactical one: require a real price move before any alert fires. Prices &lt;em&gt;do&lt;/em&gt; update off-hours (that’s the after-hours quote), even when daily candles don’t, so price movement is the honest signal that something changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// An action/confidence flip with no material price move on frozen off-hours&lt;/span&gt;
&lt;span class="c1"&gt;// data is AI scoring noise, not a real signal, so suppress it. Fail open when&lt;/span&gt;
&lt;span class="c1"&gt;// we lack both prices (can't prove it's noise) or the threshold is disabled (0).&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;triggerType&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minPriceMovePctToAlert&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;morningPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;currentPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;priceDelta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minPriceMovePctToAlert&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;triggerType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Default threshold is 1.0%, configurable. Note the fail-open conditions — if either price is missing, or you set the threshold to 0, you get the old behaviour. I didn’t want a data gap to silently mute alerts; a guard that fails closed on missing data is a guard that eventually hides something real.&lt;/p&gt;

&lt;p&gt;That module had no unit tests before this. It has them now, and the first two replay both halves of that VOO whipsaw with the numbers straight out of the logs.&lt;/p&gt;

&lt;p&gt;The structural fix: the crons were badly placed to begin with. All four sat inside the US-closed dead zone because I’d picked times convenient for &lt;em&gt;me&lt;/em&gt;, not times when the underlying data changes. Respaced ~3.75h apart across my waking window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1-5"&lt;/span&gt; &lt;span class="c1"&gt;# 1:15pm AEST (US closed)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;7&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1-5"&lt;/span&gt; &lt;span class="c1"&gt;# 5:00pm AEST (US closed)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;45&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1-5"&lt;/span&gt; &lt;span class="c1"&gt;# 8:45pm AEST (US pre-market)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;14&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1-5"&lt;/span&gt; &lt;span class="c1"&gt;# 12:30am AEST next day (US market open)&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The last two now land in US pre-market and the US open, where prices actually move. The first two still fire on frozen data, and the guard keeps them quiet unless price moved for real.&lt;/p&gt;

&lt;p&gt;One lesson I’d have written up right there, if I’d stopped at this point: &lt;strong&gt;a scheduled job’s cadence should be derived from when its inputs change, not from when you’d like to read the output.&lt;/strong&gt; I’d had that backwards for five releases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Then it happened again
&lt;/h3&gt;

&lt;p&gt;Five days after that guard shipped, I was searching my mail for something unrelated and saw this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Tue 16:08 GOOG signal weakened
Tue 19:43 GOOG signal strengthened
Tue 22:33 GOOG signal weakened

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

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwunrv62riht5a1tqm2p.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwunrv62riht5a1tqm2p.jpg" alt="Richfolio GOOG whipsaw inbox screenshot" width="116" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same whipsaw. Three flips in six and a half hours, two of them on runs where the US market was shut and the candles were frozen. The guard I’d just written, tested, and blogged about in my head was sitting right there in the code path, doing nothing.&lt;/p&gt;

&lt;p&gt;The reason is a good one, in the sense that I’d never have found it by re-reading the guard. &lt;strong&gt;GOOG is a watch-list ticker.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Watch-list tickers are deliberately excluded from &lt;code&gt;report.items&lt;/code&gt; — that’s the whole design, the thing that stops them polluting allocation percentages. But the intraday price map was built like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;priceMap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c1"&gt;// ← watch-list tickers are not in here&lt;/span&gt;
  &lt;span class="nx"&gt;priceMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;So for GOOG, &lt;code&gt;morningPrice&lt;/code&gt; and &lt;code&gt;currentPrice&lt;/code&gt; were both &lt;code&gt;0&lt;/code&gt;. And look again at what the guard requires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;triggerType&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minPriceMovePctToAlert&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;morningPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="c1"&gt;// ← false&lt;/span&gt;
  &lt;span class="nx"&gt;currentPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="c1"&gt;// ← false&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;priceDelta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minPriceMovePctToAlert&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;It &lt;strong&gt;fails open on missing prices&lt;/strong&gt; — the decision I described two paragraphs ago as deliberate, and still think is right for a genuine data gap. Except this wasn’t a data gap. The prices existed; they just weren’t in the map I handed the guard. So the guard didn’t fail open on bad data, it quietly declined to run at all, for exactly the tickers a feature in the &lt;em&gt;same release&lt;/em&gt; had introduced.&lt;/p&gt;

&lt;p&gt;Two v1.8 features that don’t compose, which is a recurring hazard in AI portfolio monitoring: the watch list can raise intraday alerts, and the guard that silences noisy intraday alerts couldn’t see watch-list tickers. Neither feature is wrong in isolation. Nobody reviewing either diff would catch it.&lt;/p&gt;

&lt;p&gt;The fix, shipped as &lt;a href="https://github.com/furic/richfolio/releases/tag/v1.8.1" rel="noopener noreferrer"&gt;v1.8.1&lt;/a&gt;, is small — one shared builder, used by both the daily baseline and the intraday comparison, so the two maps can’t drift apart again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildPriceMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AllocationReport&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;watchingItems&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The lesson I actually took, which is better than the cron one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fail-open guard cannot tell you it isn’t running.&lt;/strong&gt; Mine had no way to distinguish “I checked and this is a real move” from “I had no data so I waved it through,” and both outcomes look identical from outside: an alert arrives. I’d built a safety net, verified it on the case that motivated it, and never asked which inputs it silently didn’t cover. Every day it did nothing on GOOG, the observable behaviour was the same as a working guard on a genuinely moving stock.&lt;/p&gt;

&lt;p&gt;The concrete practice I’d extract: when a guard fails open, &lt;strong&gt;the fail-open branch deserves a log line and a test of its own&lt;/strong&gt; — not just the happy path. And the regression test that matters is not “does the guard suppress noise” (it always did, when it ran). It’s “does every ticker that can produce an alert have the data the guard needs?” That’s a coverage question about the &lt;em&gt;inputs&lt;/em&gt;, not a behaviour question about the function, and it’s the test I didn’t have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;every watch ticker gets a non-zero price (the guard needs &amp;gt; 0)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildPriceMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;watchingItems&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; must have a price or the guard fails open`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  The price that lied
&lt;/h2&gt;

&lt;p&gt;Same theme, different surface. Any AI portfolio monitoring pipeline has to decide which price is authoritative, and v1.7 already preferred the freshest quote — after-hours → pre-market → regular close — because a brief showing yesterday’s close after an earnings gap is useless.&lt;/p&gt;

&lt;p&gt;But it only swapped &lt;code&gt;quote.price&lt;/code&gt;. Everything &lt;em&gt;derived&lt;/em&gt; from price kept using the close. So on a big earnings gap the brief would show the fresh price beside a P/E and a 52-week position both still computed off the stale close — a ticker up 15% after hours would print the pre-gap P/E, making it look cheaper than the price I was actually being asked to pay. Internally inconsistent, and worse, the AI was reasoning about a valuation that didn’t match the price it was given.&lt;/p&gt;

&lt;p&gt;The fix is arithmetic, not cleverness. EPS is fixed between earnings reports, so P/E scales linearly with price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;regularPrice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trailingPE&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trailingPE&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forwardPE&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forwardPE&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fiftyTwoWeekHigh&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fiftyTwoWeekLow&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fiftyTwoWeekPercent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* recompute from latest.price within the 52w range */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Then the same question for the moving averages, where it gets more interesting. Distance from the 50/200-day MA should obviously use the fresh price — that’s the number I’m acting on. But feeding a spot price into a series derived from raw chart closes has a units trap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveTrendPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;closeLast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spotPrice&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spotPrice&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;spotPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;closeLast&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;spotPrice&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;closeLast&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;spotPrice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;closeLast&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;That ±50% band catches two real failure modes I hit. &lt;strong&gt;Sub-unit currencies&lt;/strong&gt; : LSE tickers quote in pence, and my pipeline divides &lt;code&gt;quote.price&lt;/code&gt; by 100 to normalise — but the raw chart closes aren’t divided, so the ratio comes out at 0.01 and the MA distance would read as a 99% crash. And &lt;strong&gt;thin after-hours prints&lt;/strong&gt; : an illiquid ticker can trade at an absurd price on a handful of shares after the close, which isn’t a move, it’s an artifact.&lt;/p&gt;

&lt;p&gt;The line I had to draw carefully: oscillators (RSI, MACD, Bollinger, Stochastic), ATR%, the 90-day percentile and the 1-day change all stay on completed daily closes. They’re &lt;em&gt;defined&lt;/em&gt; on the close series. You can’t retrofit a single spot price into a 14-period average and call it an update — you’d get a number that looks precise and means nothing. Only the metrics that are genuinely “price versus a reference level” get the fresh price.&lt;/p&gt;

&lt;p&gt;Both of these bugs, and the whipsaw, are the same mistake in different costumes: mixing data from two different clocks and not noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that wasn’t running
&lt;/h2&gt;

&lt;p&gt;The most expensive instance involved no bug at all — just a rule that quietly had nothing to do.&lt;/p&gt;

&lt;p&gt;Any AI portfolio monitoring setup running two models needs a policy for disagreement. Mine is the &lt;strong&gt;unanimity rule&lt;/strong&gt; : a STRONG BUY requires &lt;em&gt;every&lt;/em&gt; configured provider to vote STRONG BUY independently, otherwise the consensus caps at BUY. Four lines, and they’re the whole justification for paying two models to look at the same portfolio. (&lt;a href="https://www.richardfu.net/free-llm-api-three-model-stack/" rel="noopener noreferrer"&gt;Part 2 has the full consensus logic&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;On 23 June my system produced the strongest signal it has ever produced: a STRONG BUY on MSFT at 91% confidence, with six independent entry signals and an RSI of 9.3. I read it and acted on it. &lt;a href="https://www.richardfu.net/six-months-ai-buy-signals-results/" rel="noopener noreferrer"&gt;Part 4 covers what that trade did&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Six weeks later, writing this, I went looking for the run — and found that &lt;strong&gt;Gemini was down that day&lt;/strong&gt; , quota-exhausted. That STRONG BUY was one model, alone.&lt;/p&gt;

&lt;p&gt;Which means the unanimity rule never ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeConsensusAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProviderScore&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HOLD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← straight through&lt;/span&gt;
  &lt;span class="c1"&gt;// ... tally, tiebreak, then the unanimity rule&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;One provider, so the action passes through untouched. No unanimity check, because unanimity among one model is trivially satisfied.&lt;/p&gt;

&lt;p&gt;This is the graceful degradation I wrote up in the v1.7 notes as a feature, and it &lt;em&gt;is&lt;/em&gt; one — a provider hitting its quota shouldn’t cost me the whole brief. But look at what it produced. My largest signal skipped the exact safeguard I’d built to make STRONG BUY mean something, and &lt;strong&gt;nothing in the output said so.&lt;/strong&gt; No &lt;code&gt;avg&lt;/code&gt; tag, no agreement badge, no “1 of 2 providers responded” line. Just a clean &lt;code&gt;91%&lt;/code&gt;, identical in every visual respect to a vetted consensus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graceful degradation is fail-open by another name.&lt;/strong&gt; When Gemini dropped out, the system silently downgraded from “two models must agree” to “one model decides” — a materially weaker guarantee, presented in exactly the same language as the strong one.&lt;/p&gt;

&lt;p&gt;Which is the GOOG whipsaw wearing different clothes. There, a guard didn’t run because its inputs were missing, and the output looked like a guard that had run and approved. Here, a rule didn’t run because a provider was missing, and the output looked like a rule that had run and approved. Both times the absence of a check was invisible at exactly the moment it mattered.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/furic/richfolio/releases/tag/v1.9.0" rel="noopener noreferrer"&gt;v1.9.0&lt;/a&gt; exists because of this. Every recommendation from a degraded run now carries what was lost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;
&lt;span class="na"&gt;degradation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;configured&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;answered&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;missing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Google&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Gemini"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;and STRONG BUY caps at BUY, because cross-provider agreement is part of the STRONG BUY criteria and it demonstrably did not happen. The email shows a &lt;code&gt;⚠ 1/2 AI&lt;/code&gt; badge with the missing providers in the tooltip, Telegram shows the same tag — and both render &lt;strong&gt;even in single-provider mode&lt;/strong&gt; , which is the entire point. That’s exactly where the old code hid it.&lt;/p&gt;

&lt;p&gt;Run that same 23 June brief through today’s code and MSFT arrives as &lt;strong&gt;BUY 91% ⚠ 1/2 AI&lt;/strong&gt;. Same analysis, same reasoning, same outcome — but labelled as the unverified single-model call it actually was, which is the only thing I ever wanted from it.&lt;/p&gt;

&lt;p&gt;One case is deliberately left alone: if you only ever set one API key, nothing changes. That configuration never promised unanimity, so it isn’t degraded — no cap, no badge. It has its own negative test, because getting that wrong would silently downgrade every single-key user’s recommendations, which would be the same category of mistake in the opposite direction.&lt;/p&gt;

&lt;p&gt;Capping is configurable (&lt;code&gt;ai.strongBuyRequiresAllProviders: false&lt;/code&gt; keeps the survivor’s action). The badge isn’t. Whether a lost guarantee should change the recommendation is a judgement call; whether you get told about it isn’t.&lt;/p&gt;

&lt;h2&gt;
  
  
  What connects all of this
&lt;/h2&gt;

&lt;p&gt;Six incidents in one release window, and they’re the same incident:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The safeguard&lt;/th&gt;
&lt;th&gt;Why it wasn’t running&lt;/th&gt;
&lt;th&gt;What I saw&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frozen-data deadband&lt;/td&gt;
&lt;td&gt;watch tickers had no price in the map; the guard fails open on missing prices&lt;/td&gt;
&lt;td&gt;an alert arrived, looking like a real move&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STRONG BUY unanimity rule&lt;/td&gt;
&lt;td&gt;one provider answered, so consensus short-circuits&lt;/td&gt;
&lt;td&gt;a clean &lt;code&gt;91%&lt;/code&gt; card, looking vetted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh-price consistency&lt;/td&gt;
&lt;td&gt;P/E and 52w position still computed off the stale close&lt;/td&gt;
&lt;td&gt;a brief where every number looked like it agreed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production &lt;code&gt;state/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;my local directory is a separate lineage from the Actions cache&lt;/td&gt;
&lt;td&gt;a plausible file on disk with nothing to do with production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AI_DETAILED_PROVIDER&lt;/code&gt; fallback&lt;/td&gt;
&lt;td&gt;validated once, then overridden downstream with the raw env value&lt;/td&gt;
&lt;td&gt;a pinned provider that would fail every ticker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The test suite itself&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm test&lt;/code&gt; enumerated three filenames by hand, so new test files never ran&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;104 passing&lt;/code&gt;, green, confident&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fifth annoys me most, because the fallback was &lt;em&gt;correct&lt;/em&gt;. &lt;code&gt;pickDetailedProvider()&lt;/code&gt; checked whether the pinned provider had an API key and fell back when it didn’t. Then thirty lines later, the per-ticker loop did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="nx"&gt;explicitOverride&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gemini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;explicitOverride&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;explicitOverride&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;DetailedProviderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// ← never verified it was configured&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;and threw the decision away. I’d written the guard, written the fallback, and then quietly bypassed both from the code that consumed them. A working safeguard and a bypassed one are indistinguishable until the day the bypass matters.&lt;/p&gt;

&lt;p&gt;The last one deserves its own moment. I wrote nine tests for the v1.9 fix, ran the suite, watched it report a clean pass — and the count hadn’t moved, because the runner was a hardcoded list of three files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;- "test": "node --import=tsx/esm --test test/util.test.ts test/social.test.ts test/intradayCompare.test.ts"
&lt;/span&gt;&lt;span class="gi"&gt;+ "test": "node --import=tsx/esm --test test/*.test.ts"
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A test that isn’t executed looks exactly like a test that passed.&lt;/strong&gt; I had spent the previous hour writing about precisely this failure mode, in the tool I was using to verify the fix for it. The real total was 113 all along, with 9 of them not running.&lt;/p&gt;

&lt;p&gt;None of these six threw. Nothing logged a warning. In every case the degraded output was rendered in exactly the same visual language as the healthy output, which is why they all survived for weeks and why I found each one by accident rather than by looking.&lt;/p&gt;

&lt;p&gt;The shared root cause isn’t carelessness in any single diff — each is defensible in isolation, and I’d approve all six in review today. It’s that &lt;strong&gt;I only ever tested the presence of a check, never its absence.&lt;/strong&gt; I had tests proving the deadband suppresses noise. I had none asking which tickers it could see. I had a unanimity rule with a clear spec. I had nothing asserting it was in force on the run that produced a STRONG BUY. I had a green suite. I had nothing checking the suite was running what I thought it was.&lt;/p&gt;

&lt;p&gt;Four practices I’m taking forward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every fail-open branch gets a log line.&lt;/strong&gt; If a guard waves something through because data was missing, that’s an event, not a non-event. Silence should mean “checked and fine,” never “couldn’t check.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the inputs, not just the behaviour.&lt;/strong&gt; The regression test that would have caught the GOOG bug isn’t “does the guard suppress noise” — it always did, when it ran. It’s “does every ticker that can raise an alert have the data the guard requires?” That’s a coverage assertion about inputs, and a different shape of test than I was writing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degraded modes must be visibly degraded.&lt;/strong&gt; If two providers are configured and one answers, say so on the card. A guarantee I can silently lose is not a guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testability is a design constraint, not a chore.&lt;/strong&gt; Two modules I needed to test couldn’t be imported at all: &lt;code&gt;config.ts&lt;/code&gt; reads &lt;code&gt;config.json&lt;/code&gt; at module load and &lt;em&gt;throws&lt;/em&gt; when it’s absent, and CI runs without one — so anything importing it, directly or three hops away, was permanently untestable. That’s not a testing problem, it’s a coupling problem wearing a testing problem’s clothes. Both got split into a pure module taking config as an argument and a thin wrapper injecting it. The tests I wanted became possible the moment the coupling went.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of that is specific to AI portfolio monitoring, or to AI at all. It’s what happens when you build defences and then only ever verify them on the day you wrote them.&lt;/p&gt;

&lt;p&gt;An AI portfolio monitoring tool’s real failure mode isn’t being wrong — it’s being noisy enough that you stop reading it. I’d started ignoring my own Telegram alerts before the frozen-data guard shipped. The reason I trust them now isn’t that I added a guard; it’s that I went and checked the guard was running.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; &lt;a href="https://www.richardfu.net/free-llm-api-three-model-stack/" rel="noopener noreferrer"&gt;Part 2 — the free LLM API stack behind it&lt;/a&gt;, including the provider I had to replace when its balance ran out.&lt;/p&gt;

&lt;p&gt;Richfolio is open source: &lt;a href="https://github.com/furic/richfolio" rel="noopener noreferrer"&gt;github.com/furic/richfolio&lt;/a&gt; · &lt;a href="https://furic.github.io/richfolio" rel="noopener noreferrer"&gt;docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/ai-portfolio-monitoring-silent-failures/" rel="noopener noreferrer"&gt;AI Portfolio Monitoring: Every Safety Net Failed Silently&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>finance</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>aiportfoliomonitorin</category>
    </item>
    <item>
      <title>Background agents that feel alive and cost almost nothing</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:31:37 +0000</pubDate>
      <link>https://dev.to/furic/background-agents-that-feel-alive-and-cost-almost-nothing-eph</link>
      <guid>https://dev.to/furic/background-agents-that-feel-alive-and-cost-almost-nothing-eph</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F079snub0ow5jgvokua5q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F079snub0ow5jgvokua5q.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most games are full of things that don’t matter. The crowd behind the fence, the birds, the cars in the next lane over — none of it is gameplay, but all of it is the difference between a world and a set. The catch is that “doesn’t matter” has to also mean “doesn’t cost much,” because the moment your ambient decoration starts eating frame time you’ve paid a lot for something the player was never looking at directly.&lt;/p&gt;

&lt;p&gt;I recently built a lane of background traffic that had to read as a believable stop-and-go jam, play engine audio, and physically block the player under one specific condition — all while staying cheap enough to run on mobile WebGL. The three problems it solved are ones you hit with almost any ambient system: how to make many agents &lt;em&gt;move&lt;/em&gt; convincingly, how to make them &lt;em&gt;sound&lt;/em&gt; right without a wall of audio sources, and how to give them &lt;em&gt;physics&lt;/em&gt; without paying for it every frame.&lt;/p&gt;

&lt;p&gt;Here’s how each one works, with the game-specific parts filed off.&lt;/p&gt;

&lt;h2&gt;
  
  
  One controller, no per-agent scripts
&lt;/h2&gt;

&lt;p&gt;Before any of the tricks: the whole system is a single &lt;code&gt;MonoBehaviour&lt;/code&gt; that owns a flat list of lightweight structs. There are no &lt;code&gt;MonoBehaviour&lt;/code&gt;s on the individual agents, no per-agent &lt;code&gt;Update&lt;/code&gt;, and — until the very end — no colliders. Every agent is just a &lt;code&gt;Transform&lt;/code&gt; plus a few floats.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Transform&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;halfLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// measured from renderer bounds at spawn&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// current, always &amp;gt;= 0&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;rolling&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// in a "go" phase, or paused?&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;moveBudget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// distance still owed this go-phase&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;stopTimer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// seconds left in the current pause&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;This matters more than it looks. A hundred &lt;code&gt;Update()&lt;/code&gt; calls have real overhead in the engine’s script dispatch, and a hundred &lt;code&gt;MonoBehaviour&lt;/code&gt;s is a hundred managed objects the GC has to know about. One loop over a &lt;code&gt;List&amp;lt;Agent&amp;gt;&lt;/code&gt; is cache-friendly, trivially cheap, and — a nice side effect — completely deterministic if you feed it your own RNG instead of the global one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A jam that moves like a jam
&lt;/h2&gt;

&lt;p&gt;The naive version of moving traffic is a conveyor: every car drives at the same constant speed, spacing never changes. It reads as “cars exist,” never as “traffic.” A jam is the opposite — dense, mostly stopped, lurching forward in fits. Getting that feel taught me two non-obvious lessons.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pack tight, brake late
&lt;/h3&gt;

&lt;p&gt;The obvious model is car-following: each agent looks at the gap to the one ahead and picks a safe speed from it. The standard safe-following formula is&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;brake&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gapAhead&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;stopDistance&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;which is just “how fast can I go and still brake to a stop before I hit them.” Reasonable. But look at what it does when cars are packed bumper-to-bumper: &lt;code&gt;gapAhead ≈ stopDistance&lt;/code&gt;, so &lt;code&gt;safe ≈ 0&lt;/code&gt;. &lt;strong&gt;The default state of a tightly packed line is frozen.&lt;/strong&gt; Nothing moves unless something ahead of it opens room first, which means motion can only ever trickle backward from whatever agent happens to have open road in front of it.&lt;/p&gt;

&lt;p&gt;That’s fine for a line whose leader has somewhere to go. It is &lt;em&gt;fatal&lt;/em&gt; for a line whose leader doesn’t — say, oncoming traffic in a world that scrolls past the player, where the “front” of the line is constantly being consumed at the edge before it can pull anyone. One lane flows, the mirror-image lane sits frozen for ten seconds at a time. Same code, opposite behaviour, purely because of which end has runway.&lt;/p&gt;

&lt;p&gt;The fix is to stop conflating two different distances:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Packing distance&lt;/strong&gt; — how close cars &lt;em&gt;sit&lt;/em&gt; when stopped (visual density).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Braking distance&lt;/strong&gt; — how close a car gets before it actually brakes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make the braking distance &lt;em&gt;much smaller&lt;/em&gt; than the packing distance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// packingGap ≈ 2.5 (how dense the jam looks)&lt;/span&gt;
&lt;span class="c1"&gt;// brakeGap ≈ 0.6 (how close before braking)&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;brake&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gapAhead&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;brakeGap&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Now a car sitting at its normal packed spacing still has &lt;code&gt;packingGap - brakeGap&lt;/code&gt; of headroom, so it can &lt;em&gt;creep&lt;/em&gt; at full crawl speed. The line stops behaving like a gridlock (“frozen unless pulled”) and starts behaving like a conveyor (“moving, braking only to avoid a bump”). Direction stops mattering. Every lane flows.&lt;/p&gt;

&lt;p&gt;This is the kind of bug that’s invisible in the math and obvious the moment you watch it: the model was technically correct and produced a dead lane.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commit to a distance, not a duration
&lt;/h3&gt;

&lt;p&gt;For stop-and-go, each agent alternates between “go” and “stop” phases. The tempting way to time a “go” phase is a clock: &lt;em&gt;drive for N seconds, then pause.&lt;/em&gt; Don’t. In a jam an agent spends most of a wall-clock “go” phase &lt;strong&gt;blocked&lt;/strong&gt; — pressed up behind a stopped car, burning down its timer while going nowhere. By the time the road clears it has almost no “go” left, so it lurches forward a few centimetres and stops again. The whole jam does a nervous little shuffle.&lt;/p&gt;

&lt;p&gt;Commit each move to a &lt;strong&gt;distance&lt;/strong&gt; instead, and spend that budget only with &lt;em&gt;actual forward progress&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rolling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stopTimer&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stopTimer&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rolling&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;moveBudget&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Rand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.8f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;halfLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 0.8–2 body-lengths&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rolling&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;crawlSpeed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MoveTowards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;desired&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;accel&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;brake&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;movedBefore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;ClampBehindNeighbour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brakeGap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// never overlap the one ahead&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;actuallyMoved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;movedBefore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// could be ~0 if blocked&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rolling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;moveBudget&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actuallyMoved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// blocked time doesn't count&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;moveBudget&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rolling&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stopTimer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Rand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.4f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1.2f&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Because the budget only drains when the agent genuinely advances, every move covers its full one-or-two body-lengths no matter how long the agent waited for the gap to open. Blocked cars wait patiently; when the road clears they pull forward a real, visible distance and then brake. The randomised stop durations, staggered across the line, produce the backward-travelling “wave” that reads unmistakably as a jam.&lt;/p&gt;

&lt;h3&gt;
  
  
  An endless belt from a finite list
&lt;/h3&gt;

&lt;p&gt;You never need more agents than fit on screen. Anchor a window to the camera; spawn agents to fill it; when one falls off the trailing edge, teleport it to the leading edge and re-pack it against the current front of the line. Because agents in a single lane can’t overtake (a follower always brakes before passing), their order never changes — which means recycling is just “pop the tail, push the head,” no sorting, no bookkeeping. A few dozen agents give you an infinite road.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Audio that only plays when it should
&lt;/h2&gt;

&lt;p&gt;Give every agent a looping &lt;code&gt;AudioSource&lt;/code&gt; and press play, and you get two problems at once: dozens of voices competing for mixer time, and a muddy drone that never stops. Neither is what “a car passing you” sounds like.&lt;/p&gt;

&lt;p&gt;The better model treats the &lt;em&gt;clip&lt;/em&gt; as a steady bed and does all the shaping in code. Each frame, decide whether this agent should be audible right now — is it actually moving, and is it within earshot of the listener — and drive the volume toward that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;audible&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;movingEps&lt;/span&gt;
            &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;sqrMagnitude&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;earshot&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;earshot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audible&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;baseVolume&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;volume&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MoveTowards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;volume&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseVolume&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;fadeTime&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;volume&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0.0005f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPlaying&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Play&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPlaying&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Three things fall out of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You only ever pay for audible voices.&lt;/strong&gt; A source that isn’t near the listener never gets &lt;code&gt;Play()&lt;/code&gt;d, so it costs nothing. In practice, out of ~80 agents only a handful are ever playing at once. 3D spatial blend and distance rolloff still handle the actual attenuation; the earshot check just avoids spinning up voices you’d never hear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Motion gates sound.&lt;/strong&gt; A stationary agent stays silent even if the listener is right next to it. That’s a deliberate design choice — a stopped car doesn’t make a pass-by noise — and it’s a one-line consequence of the &lt;code&gt;a.speed &amp;gt; movingEps&lt;/code&gt; term.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fade is free and continuous.&lt;/strong&gt; &lt;code&gt;MoveTowards&lt;/code&gt; ramps the volume up when the agent starts moving and down when it stops, so the sound lasts exactly as long as the motion, with no clicks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One trap worth calling out: &lt;strong&gt;your loop clip must be constant-volume.&lt;/strong&gt; A clip with a baked fade-in/fade-out envelope (very common for one-shot “whoosh” sounds) will &lt;em&gt;pulse&lt;/em&gt; when you loop it, and pulse even worse when several overlap. If you’re repurposing one-shot assets into loops, flatten them to constant RMS and trim the quiet tails first, so the only volume shaping is the one you’re doing in code. Otherwise you’ll spend an afternoon convinced your fade logic is broken when it’s the source material.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Physics you don’t pay for until you need it
&lt;/h2&gt;

&lt;p&gt;The system’s agents move every single frame. That detail alone dictates the entire physics approach, because of a mistake that’s very easy to make: &lt;strong&gt;a bare collider that moves is a static collider that moves, and moving a static collider forces the physics engine to rebuild its static broadphase tree — every frame, for every collider that moved.&lt;/strong&gt; With a lot of moving colliders that’s a genuine frame-killer, and it’s worse on single-threaded WebGL builds.&lt;/p&gt;

&lt;p&gt;The rule is simple: &lt;em&gt;if a collider moves, it needs a kinematic &lt;code&gt;Rigidbody&lt;/code&gt;.&lt;/em&gt; That tells the engine “this thing moves, treat it as a moving body,” and the static-tree rebuild goes away.&lt;/p&gt;

&lt;p&gt;But there’s a second, bigger optimisation available if collisions only matter &lt;em&gt;sometimes&lt;/em&gt;. In my case the agents only ever needed to physically block the player under one rare condition. During normal play they were pure decoration. So the colliders start &lt;strong&gt;disabled&lt;/strong&gt; — zero broadphase cost — and only wake up, near the player, when that condition fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// At spawn: a disabled, kinematic collider on a dedicated layer.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;go&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddComponent&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoxCollider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// costs nothing until enabled&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;go&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddComponent&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Rigidbody&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isKinematic&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// moving collider, no static-tree churn&lt;/span&gt;
&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;useGravity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;go&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agentLayer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// On the rare event, wake only what's near the actor — and keep following it,&lt;/span&gt;
&lt;span class="c1"&gt;// because the actor is moving too.&lt;/span&gt;
&lt;span class="n"&gt;IEnumerator&lt;/span&gt; &lt;span class="nf"&gt;WakeColliders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Transform&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wakeRadius&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;wakeRadius&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Vector3&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt;
                &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;sqrMagnitude&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;So the physics bill is: nothing for the entire normal run, and then a handful of small kinematic boxes for a few seconds around one event. A box collider is the cheapest shape there is, and waking three of them near the player is not something any profiler will ever flag.&lt;/p&gt;

&lt;p&gt;Two supporting details make this behave:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Freeze the movement while the colliders are live.&lt;/strong&gt; If your agents recycle or teleport (like the endless belt above), an agent the player is leaning against could suddenly warp away mid-contact. Halting agent movement for the duration of the event keeps the collision honest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a dedicated layer and the collision matrix.&lt;/strong&gt; Put the agents on their own physics layer and configure the matrix so that layer &lt;em&gt;only&lt;/em&gt; interacts with the one actor that matters. This isn’t about performance so much as correctness: it stops the player’s trigger volumes (the ones that detect pickups, gates, whatever) from firing on your ambient props. A physics layer is a filter, and the cheapest collision is the pair the broadphase never even considers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;None of these is a clever algorithm. They’re all the same instinct applied three ways: &lt;strong&gt;spend effort only where the player can perceive the result, and never a frame before.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The jam runs from one controller over a flat list, and only cares about the window on screen.&lt;/li&gt;
&lt;li&gt;Audio spins up a voice only for agents that are both moving and within earshot, and lets everything else stay silent for free.&lt;/li&gt;
&lt;li&gt;Physics stays completely inert until a specific moment, then wakes only the few colliders near the player.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ambient systems are where budgets quietly go to die, because “it’s just background” makes it easy to justify a little waste per object, and then you have four hundred objects. The fix isn’t heroic optimisation — it’s noticing that the player can only look at, listen to, and bump into a tiny slice of the world at any instant, and building each system so that slice is the only part that costs anything.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/background-agents-that-feel-alive-and-cost-almost-nothing/" rel="noopener noreferrer"&gt;Background agents that feel alive and cost almost nothing&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>gamedev</category>
      <category>unity3d</category>
      <category>ambienteffect</category>
    </item>
    <item>
      <title>Particle Trail Stuck at X=0: A Unity Update-Order Bug (and Six Wrong Fixes)</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:25:04 +0000</pubDate>
      <link>https://dev.to/furic/particle-trail-stuck-at-x0-a-unity-update-order-bug-and-six-wrong-fixes-3m5o</link>
      <guid>https://dev.to/furic/particle-trail-stuck-at-x0-a-unity-update-order-bug-and-six-wrong-fixes-3m5o</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkibd0nbu80t3mrilr9jq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkibd0nbu80t3mrilr9jq.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wanted a simple thing: a little particle trail that streams behind a fast-moving character as it runs down a lane and slides left/right. A few hours and &lt;strong&gt;six failed fixes&lt;/strong&gt; later, I finally understood why the particles kept piling up in the dead center of the lane while the character was clearly off to one side.&lt;/p&gt;

&lt;p&gt;The bug had one strange, very specific signature that I ignored for far too long:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The trail followed the character in &lt;strong&gt;Y and Z&lt;/strong&gt; , but its &lt;strong&gt;X was always pinned to 0.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That asymmetry was the whole answer. It took me most of the session to listen to it. This is the honest version — wrong turns first, then the root cause, then the fix and the lessons.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A Unity 6 / URP game. A character runs forward automatically and is nudged left/right between lanes. I attach a looping CPU particle trail (a standard Shuriken system from an art pack) to the character so it leaves a fun streak.&lt;/p&gt;

&lt;p&gt;In the editor, if I grab the character and drag it around by hand, the trail follows perfectly. In play mode, the streak renders in the &lt;strong&gt;center of the lane&lt;/strong&gt; no matter where the character actually is. New particles don’t appear at the character — they appear at world X ≈ 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #1 — “the spawn position is wrong”
&lt;/h2&gt;

&lt;p&gt;My first instinct: I’m instantiating the trail at the wrong place, or not parenting it. I “fixed” the parenting and zeroed the local position. No change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #2 — “stop re-instantiating; cache and reuse”
&lt;/h2&gt;

&lt;p&gt;I rewrote it to pre-instantiate one trail per type and just activate/deactivate them, reasoning that per-event &lt;code&gt;Instantiate&lt;/code&gt; was somehow mis-placing things. Cleaner code. Same bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #3 — “it’s World-space particle semantics + speed”
&lt;/h2&gt;

&lt;p&gt;I reasoned that with &lt;strong&gt;World simulation space&lt;/strong&gt; , particles are stamped into the world where they’re born and left behind, and with a long lifetime over a fast run they’d stretch back toward the start (≈ X=0). So I shortened particle lifetime to make a tight “comet tail.” Same bug — still centered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #4 — “use Local space, it’ll stick to the character”
&lt;/h2&gt;

&lt;p&gt;I forced &lt;strong&gt;Local simulation space&lt;/strong&gt;. Now the particles &lt;em&gt;did&lt;/em&gt; stick to the character… but they clustered on it with no trailing streak at all. I’d traded one wrong behavior for another, and — critically — I never confirmed Local had actually taken effect (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #5 — “add a world-space backward velocity to fake the streak”
&lt;/h2&gt;

&lt;p&gt;Local emission + a world-space backward velocity to make particles hang behind. This threw &lt;code&gt;Particle Velocity curves must all be in the same mode&lt;/code&gt; (you must set X/Y/Z velocity curves together), and when fixed, &lt;em&gt;re-introduced&lt;/em&gt; the X=0 behavior. I’d reintroduced a world-space transform path and the bug came back with it.&lt;/p&gt;

&lt;p&gt;At this point I had changed six things and understood nothing. That’s the signal to &lt;strong&gt;stop guessing and start measuring.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurements that cracked it
&lt;/h2&gt;

&lt;p&gt;Two probes finally produced facts instead of theories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe 1 — the emitter vs. the particles.&lt;/strong&gt; I logged, every frame, the emitter’s world position and the particle system’s &lt;strong&gt;render-bounds center&lt;/strong&gt; (where the particles actually are in the world):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
emitter world = (1.04, 0.69, 30.28)
particle bounds center = (-0.04, 0.69, 30.24)

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

&lt;/div&gt;



&lt;p&gt;The emitter was genuinely at X=1.04. The particles were at X≈0. &lt;strong&gt;Y and Z matched the emitter; only X was wrong.&lt;/strong&gt; So this was never a parenting bug — the GameObject &lt;em&gt;was&lt;/em&gt; on the character. The particles were being &lt;em&gt;born&lt;/em&gt; at the wrong X.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe 2 — what simulation space is actually running.&lt;/strong&gt; The same log printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
sim = World

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

&lt;/div&gt;



&lt;p&gt;…even though my code now set the field default to &lt;code&gt;Local&lt;/code&gt;. That’s a Unity trap I’d walked straight into:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Changing a field’s default in code does not change the value already serialized on a component.&lt;/strong&gt; The component had been saved with &lt;code&gt;simulationSpace = World&lt;/code&gt; earlier, so every “Local” change in code was silently ignored.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So my “Wrong turn #4” had never even run as Local. Once I set the &lt;em&gt;serialized&lt;/em&gt; value to Local, the particles finally tracked the character’s X — confirming the bug was specific to &lt;strong&gt;World-space emission&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: particle trail emission timing
&lt;/h2&gt;

&lt;p&gt;Now the axis asymmetry made total sense. The character’s motion was split across two scripts on &lt;strong&gt;two different update steps&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forward (Z)&lt;/strong&gt; was applied in &lt;code&gt;Update&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lateral (X)&lt;/strong&gt; was applied as an &lt;em&gt;override&lt;/em&gt; in &lt;code&gt;LateUpdate&lt;/code&gt; — a second controller wrote &lt;code&gt;transform.position.x = laneX&lt;/code&gt; late in the frame.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most of the frame, the character’s X sat at ~0 (the forward controller pulls it toward a neutral target); only &lt;code&gt;LateUpdate&lt;/code&gt; slammed it back to the real lane value. A &lt;strong&gt;World-space particle system samples the emitter’s position when it emits — before that LateUpdate correction lands — so every particle was stamped at X≈0.&lt;/strong&gt; Z was never overridden late, so Z was always correct. Hence: &lt;strong&gt;follows Y and Z, stuck at X=0.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It was never the prefab, the parenting, the scale, the lifetime, or the simulation space &lt;em&gt;by itself&lt;/em&gt;. It was &lt;strong&gt;frame update order&lt;/strong&gt; colliding with how/when particle systems read the emitter transform. Any World-space particle trail attached to this character would break identically — so “try a different particle pack” would have wasted even more time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a Local-space particle trail
&lt;/h2&gt;

&lt;p&gt;I stopped trying to make World-space emission work and rebuilt the particle trail on &lt;strong&gt;Local&lt;/strong&gt; space, which is immune to the timing problem because it emits relative to the emitter no matter when the transform settles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local simulation space&lt;/strong&gt; — emission is always at the character (correct X). Confirmed by render-bounds tracking the emitter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A local −Z backward drift ≈ the run speed&lt;/strong&gt; — so particles, born at the character, drift backward in the character’s frame at roughly the speed the character moves forward. Net world motion ≈ zero, so they &lt;em&gt;hang on the ground and fade&lt;/em&gt; — a real leave-behind trail, but produced by velocity rather than by world-space emission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable the prefab’s Limit-Velocity (clamp) module&lt;/strong&gt; — it was capping speed to ~1 and damping the backward drift to nothing (particles clustered). This was the difference between “blob on the character” and “streak.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower the particles’ Start Speed&lt;/strong&gt; — the prefab fired them outward at random; with the clamp off, that randomness sprayed everywhere. A low start speed lets the drift line them up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spawn-per-event, self-despawning&lt;/strong&gt; — each pickup spawns its own short-lived instance that stops emitting after its duration and destroys itself once the tail fades; long-lived trails are stopped on a game event. Simple, no pooling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything became an inspector knob (drift speed, lifetime, start speed, emit height per trail type), because the “right” values are pure visual taste.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls, collected
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Listen to axis asymmetry.&lt;/strong&gt; “Y and Z are fine, X is wrong” is not a particle problem — it’s a &lt;em&gt;which-thing-touches-X-differently&lt;/em&gt; problem. That should have pointed me at the lateral controller and update order in minute five.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the particles, not the emitter.&lt;/strong&gt; I kept confirming the emitter followed the character (it did) and concluding “so it should work.” The particles’ render-bounds center was the only thing that mattered, and I logged it last instead of first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t trust idle samples.&lt;/strong&gt; An early probe showed the character at &lt;code&gt;(0,0,0)&lt;/code&gt; and I briefly concluded “the world scrolls, the character is stationary.” It was just sampling before the run started. One bad sample sent me down a whole wrong model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serialized values silently override code defaults.&lt;/strong&gt; Changing a &lt;code&gt;public&lt;/code&gt; field’s initializer doesn’t touch components already saved in a scene/prefab. If a code change “does nothing,” verify the &lt;em&gt;actual runtime value&lt;/em&gt;, not the source default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Particle velocity curves are all-or-nothing per mode.&lt;/strong&gt; Set X/Y/Z together or Unity throws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A “Limit Velocity over Lifetime” module will quietly eat any velocity you add.&lt;/strong&gt; If your forces seem to do nothing, check the clamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Six fixes without a measurement is a process failure, not bad luck.&lt;/strong&gt; The rule should kick in much earlier: after the &lt;em&gt;second&lt;/em&gt; failed fix, stop and instrument.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Self-reflection
&lt;/h2&gt;

&lt;p&gt;The technical lesson is real (update order vs. particle emission timing, and Local-space + drift as a robust workaround). But the meta-lesson is the one that actually cost the hours: I pattern-matched to plausible causes — parenting, instantiation, world-space semantics — and “fixed” each before I had any evidence it was the cause. Every one of those fixes was a guess wearing a confidence costume.&lt;/p&gt;

&lt;p&gt;The turning point wasn’t cleverness; it was switching from &lt;em&gt;explaining&lt;/em&gt; to &lt;em&gt;measuring&lt;/em&gt; — logging the emitter against the real particle positions, and printing the runtime simulation space instead of assuming my code default applied. Both took five minutes and immediately ended the guessing. The clue I needed (Y/Z follow, X doesn’t) was in the very first bug report. Next time: when a symptom is that specific, treat the specificity as the lead, and reach for the probe before the fix.&lt;/p&gt;

&lt;p&gt;And there was a second clue I held the whole time and failed to read. Early on I’d parented the trail under the character and, &lt;strong&gt;in play mode, dragged the character around by editing its Transform position by hand&lt;/strong&gt; — and the trail followed perfectly. I filed that under “so the parenting is fine” and moved on. But it was telling me something sharper: a manual Inspector edit sets the transform &lt;em&gt;directly and statically&lt;/em&gt; — it is &lt;strong&gt;not&lt;/strong&gt; re-applied every frame by a script, so it never participates in the Update→LateUpdate tug-of-war that the runtime controllers do. With the scripts off the hook, the emitter X just sat at the value I dragged it to, and World-space emission sampled it correctly. The only difference between “works” (manual drag) and “broken” (play mode) was &lt;strong&gt;whether scripted, frame-ordered position updates were involved at all.&lt;/strong&gt; That contrast points almost directly at update timing — if I’d asked “what is &lt;em&gt;different&lt;/em&gt; about how position gets set in the two cases?” instead of “is it parented?”, I’d have arrived at the LateUpdate override hours earlier. A test that works is not just reassurance; the &lt;em&gt;reason&lt;/em&gt; it works is often the diagnosis.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/particle-trail-stuck-at-x-zero/" rel="noopener noreferrer"&gt;Particle Trail Stuck at X=0: A Unity Update-Order Bug (and Six Wrong Fixes)&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>unity3d</category>
      <category>vfx</category>
      <category>updateorder</category>
    </item>
    <item>
      <title>Unity WebGL Particles Invisible? Causes and How to Fix It</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sun, 28 Jun 2026 15:39:18 +0000</pubDate>
      <link>https://dev.to/furic/unity-webgl-particles-invisible-causes-and-how-to-fix-it-358a</link>
      <guid>https://dev.to/furic/unity-webgl-particles-invisible-causes-and-how-to-fix-it-358a</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgot7v0rjixfwixy9f2i1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgot7v0rjixfwixy9f2i1.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I spent the better part of a day chasing a celebration firework that played perfectly in the Unity editor and rendered &lt;strong&gt;absolutely nothing&lt;/strong&gt; in the WebGL build. No errors. The particles spawned, played, sat right in front of the camera — and stayed invisible.&lt;/p&gt;

&lt;p&gt;I diagnosed it confidently &lt;strong&gt;four times.&lt;/strong&gt; The first three were wrong. The only thing that ever found truth was a dumb A/B test &lt;strong&gt;in the actual build&lt;/strong&gt; — not in the editor, not by reading shaders, not by theorizing. That turned out to be the real lesson, so I’ll tell it honestly — wrong turns and all — and then map out what WebGL genuinely can and can’t do with particles.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The setup&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A Unity 6 / URP mobile-style game shipped to &lt;strong&gt;WebGL&lt;/strong&gt;. The win screen opens a treasure chest with a firework burst and a soft neon glow over the whole scene. Two things broke &lt;em&gt;only&lt;/em&gt; in the WebGL build:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;bloom/glow&lt;/strong&gt; was missing.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;firework&lt;/strong&gt; was completely invisible — despite the particle system clearly running.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They turned out to be unrelated, which is part of why this took so long.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #1 — right, but only for the glow: shader variant stripping
&lt;/h2&gt;

&lt;p&gt;URP strips “unused” shader variants from builds to save size, and the editor never strips. So &lt;em&gt;editor-works / build-doesn’t&lt;/em&gt; should make you suspect stripping first.&lt;/p&gt;

&lt;p&gt;The missing &lt;strong&gt;bloom&lt;/strong&gt; was exactly that — URP’s Bloom post-processing variant was being stripped on WebGL (&lt;code&gt;Strip Unused Post Processing Variants&lt;/code&gt;). Disabling it brought the glow back (I later moved the glow to &lt;strong&gt;MK Glow&lt;/strong&gt; , a renderer feature — see the quality-tier trap below).&lt;/p&gt;

&lt;p&gt;Good fix — for the glow. Then I made the classic mistake: I assumed the firework had the &lt;em&gt;same&lt;/em&gt; villain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #2 — “the Shader Graph must render black on GLES3”
&lt;/h2&gt;

&lt;p&gt;The firework was from a popular Shader-Graph-based pack (Cartoon FX Remaster). I instrumented the celebration code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
firework diag @t=2.5s: totalParticles=164 anyPlaying=True anyVisible=True
shaders=[Cartoon FX/Remaster/Particle Procedural Glow, Particle Ubershader]

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

&lt;/div&gt;



&lt;p&gt;Particles &lt;strong&gt;exist&lt;/strong&gt; , system &lt;strong&gt;playing&lt;/strong&gt; , renderer &lt;strong&gt;on-screen&lt;/strong&gt; , &lt;strong&gt;real&lt;/strong&gt; shaders (not the magenta error shader). Yet no visible color — even when I cranked a glow pass to blow out the whole screen, the firework area simply never appeared.&lt;/p&gt;

&lt;p&gt;My conclusion sounded airtight: &lt;strong&gt;Unity WebGL = WebGL 2.0 = OpenGL ES 3.0&lt;/strong&gt; , and Unity cross-compiles its HLSL down to GLSL ES 3.00. For a big particle “ubershader,” I figured the cross-compiler was emitting GLSL that compiles but outputs black — a translation bug that bites only on the GLES3 path, never in the editor (Metal/D3D).&lt;/p&gt;

&lt;p&gt;Plausible. Confident. &lt;strong&gt;Wrong&lt;/strong&gt; — as I’d find out two diagnoses later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that worked — for a different reason than I thought
&lt;/h2&gt;

&lt;p&gt;I swapped the Shader-Graph firework for an &lt;strong&gt;Epic Toon FX&lt;/strong&gt; firework — CPU &lt;code&gt;ParticleSystem&lt;/code&gt; (Shuriken) + &lt;code&gt;URP/Particles/Unlit&lt;/code&gt;. Plain, old, boring tech. It rendered in the editor immediately.&lt;/p&gt;

&lt;p&gt;…and was &lt;strong&gt;invisible in the WebGL build.&lt;/strong&gt; Again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong turn #3 → the first &lt;em&gt;confirmed&lt;/em&gt; truth: Soft Particles need a depth texture
&lt;/h2&gt;

&lt;p&gt;Same signature — plays in editor, audio + feedback fire (so it’s emitting), nothing on screen. But this time the cause was real and provable. I inspected the live materials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
shader: Universal Render Pipeline/Particles/Unlit
keywords: [..._SOFTPARTICLES_ON...] _SoftParticlesEnabled = 1
URP 'High' (WebGL tier): supportsCameraDepthTexture = False

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Soft Particles&lt;/strong&gt; — the fade where a particle meets opaque geometry — needs the &lt;strong&gt;camera depth texture&lt;/strong&gt; , and the WebGL tier wasn’t generating one. With no depth bound, the fade reads an unbound texture: desktop returns “far” (visible); &lt;strong&gt;WebGL GLES3 returns 0 → alpha ≈ 0 → fully transparent.&lt;/strong&gt; Same material, opposite result per platform. No shader error — it compiles fine and fades itself to nothing.&lt;/p&gt;

&lt;p&gt;Confirmed via the WebGL console (Playwright): &lt;strong&gt;zero shader errors&lt;/strong&gt; — a runtime fade, not a compile failure. The fix was one toggle: &lt;strong&gt;turn Soft Particles off&lt;/strong&gt; (an open-air firework never intersects geometry, so they did nothing anyway). The firework finally rendered on WebGL. 🎆 It’s also why the &lt;strong&gt;coins&lt;/strong&gt; were fine the whole time: opaque, no soft particles, no depth dependency.&lt;/p&gt;

&lt;p&gt;So I’d “solved” it — and, generalizing, told myself the &lt;em&gt;original&lt;/em&gt; CFXR firework must have been soft particles too. Tidy unified theory. Also wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reckoning: a 30-second A/B test demolishes my Shader-Graph theory
&lt;/h2&gt;

&lt;p&gt;The chests also carried a CFXR glow halo (&lt;code&gt;CFXR3 LightGlow A (Loop)&lt;/code&gt;). If “CFXR renders black on WebGL” were true, those would be invisible too. So I tested it the only way that matters — &lt;strong&gt;in the build&lt;/strong&gt; : two chests at the start line, one with the CFXR glow, one without.&lt;/p&gt;

&lt;p&gt;The one &lt;strong&gt;with&lt;/strong&gt; the CFXR glow &lt;strong&gt;glowed.&lt;/strong&gt; On WebGL. The pack I’d declared “renders black” was rendering just fine.&lt;/p&gt;

&lt;p&gt;So I pulled the shader &lt;strong&gt;keywords&lt;/strong&gt; of a CFXR material that &lt;em&gt;works&lt;/em&gt; (the glow) and one that &lt;em&gt;fails&lt;/em&gt; (the firework):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
WORKS "cfxr stretch ray blur add" → _CFXR_ADDITIVE, _FADING_ON
FAILS "cfxr stretch trait blur add" → _CFXR_ADDITIVE, _FADING_ON ← identical

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Identical shader, identical keywords, identical soft-particle settings — one renders, one doesn’t.&lt;/strong&gt; That kills every shader-level theory at once: not the Shader Graph, not the variant, not soft particles, not HDR. The &lt;em&gt;same compiled shader&lt;/em&gt; draws in the loop-glow and draws nothing in the firework burst.&lt;/p&gt;

&lt;p&gt;The real differentiator is &lt;strong&gt;at the ParticleSystem / effect level&lt;/strong&gt; — the failing effects are complex one-shot &lt;strong&gt;bursts&lt;/strong&gt; (sub-emitters, velocity, lifetime curves, the pack’s lifecycle script); the working one is a simple &lt;strong&gt;loop glow&lt;/strong&gt;. Something in the burst machinery doesn’t survive the WebGL runtime — particles spawn and the renderer reports on-screen, but they contribute no visible pixels. I never pinned the exact module, and didn’t need to: the game ships the working glow + the fixed CPU firework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest scoreboard:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;glow&lt;/strong&gt; was shader-variant stripping. ✅ correct&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;CPU firework&lt;/strong&gt; was Soft Particles + no depth. ✅ confirmed&lt;/li&gt;
&lt;li&gt;“CFXR renders black on GLES3”? ❌ &lt;strong&gt;wrong.&lt;/strong&gt; CFXR renders on WebGL; &lt;em&gt;some&lt;/em&gt; of its burst effects don’t, for a non-shader reason I never proved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three confident diagnoses, one survivor. The thing that exposed each wrong one was the &lt;strong&gt;build&lt;/strong&gt; , not the editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Unity WebGL particles actually render (a GPU-feature wall)
&lt;/h2&gt;

&lt;p&gt;The useful map. WebGL 2.0 / GLES3 supports modern &lt;em&gt;vertex/fragment&lt;/em&gt; shaders (GLSL ES 3.00) but has &lt;strong&gt;no compute, geometry, or tessellation shaders.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tech&lt;/th&gt;
&lt;th&gt;Runs on WebGL2?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Built-in / URP standard shaders&lt;/td&gt;
&lt;td&gt;✅ always&lt;/td&gt;
&lt;td&gt;simple frag/vert, cross-compiles cleanly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU &lt;code&gt;ParticleSystem&lt;/code&gt; (Shuriken)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;CPU sim + standard shaders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shader Graph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ usually&lt;/td&gt;
&lt;td&gt;generates a normal shader; mostly fine on GLES3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VFX Graph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ never&lt;/td&gt;
&lt;td&gt;GPU-simulated via &lt;strong&gt;compute shaders&lt;/strong&gt; — absent on WebGL2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geometry / tessellation&lt;/td&gt;
&lt;td&gt;❌ never&lt;/td&gt;
&lt;td&gt;absent on WebGL2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I deliberately &lt;em&gt;softened&lt;/em&gt; the Shader Graph row from my first draft — I’d written “can render black,” but my own A/B test later showed the Shader Graph renders fine and the failures were elsewhere. Shader Graph is an &lt;strong&gt;authoring tool&lt;/strong&gt; that emits an ordinary shader: treat it as “usually fine, verify in build,” not “cursed.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VFX Graph&lt;/strong&gt; is the categorical wall: it &lt;em&gt;simulates particles on the GPU via compute shaders&lt;/em&gt;, and WebGL2 has no compute, so it &lt;strong&gt;can’t run at all.&lt;/strong&gt; That’s why “Realistic Fireworks — VFX Graph, supports URP!” asset-store packs are a non-starter for WebGL.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“Supports URP” describes the render pipeline, not the platform.&lt;/strong&gt; Your constraint is WebGL, not URP.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A bonus trap: the editor lied via the quality tier
&lt;/h2&gt;

&lt;p&gt;When I added MK Glow it rendered in the WebGL build but did &lt;em&gt;nothing&lt;/em&gt; in the editor — sliders had no effect. I burned time on render-graph theories before introspecting the live editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
QualityLevel 2 (PC) → PC_Renderer (no MK Glow)
QualityLevel 3 (High)→ Default_Forward_Renderer (has MK Glow) ← WebGL uses this

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

&lt;/div&gt;



&lt;p&gt;The editor was previewing on the &lt;strong&gt;PC&lt;/strong&gt; tier — a &lt;em&gt;different renderer&lt;/em&gt; than WebGL’s &lt;strong&gt;High&lt;/strong&gt; tier — so my feature wasn’t even in the active pipeline. Setting the editor to High gave instant preview. Bonus gotchas from the same hole: the &lt;strong&gt;Scene view doesn’t render custom renderer-feature passes&lt;/strong&gt; (only the Game view does), and &lt;strong&gt;Volume edits made during Play mode revert on Stop.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Unity ↔ three.js (because the compute wall is universal)
&lt;/h2&gt;

&lt;p&gt;The same wall shows up in three.js — with a different escape hatch.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Unity&lt;/th&gt;
&lt;th&gt;three.js&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shader authoring abstraction&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Shader Graph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;TSL&lt;/strong&gt; (Three.js Shading Language)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw shader code&lt;/td&gt;
&lt;td&gt;HLSL / ShaderLab&lt;/td&gt;
&lt;td&gt;GLSL / WGSL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU compute&lt;/td&gt;
&lt;td&gt;Compute Shaders (HLSL)&lt;/td&gt;
&lt;td&gt;WebGPU compute / &lt;strong&gt;TSL compute fns&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU particle sim (compute)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;VFX Graph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GPU-compute particles (TSL + WebGPU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU particle sim&lt;/td&gt;
&lt;td&gt;Shuriken &lt;code&gt;ParticleSystem&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;CPU-updated &lt;code&gt;Points&lt;/code&gt;/sprites&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things people (me included) get backwards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TSL ≈ Shader Graph, not VFX Graph.&lt;/strong&gt; It’s an &lt;em&gt;authoring layer&lt;/em&gt; — you write shading logic in a JS node API and TSL compiles it to &lt;strong&gt;WGSL&lt;/strong&gt; (WebGPU) or &lt;strong&gt;GLSL&lt;/strong&gt; (WebGL2 fallback). It can &lt;em&gt;also&lt;/em&gt; author compute, but that’s one capability, not its identity.&lt;/li&gt;
&lt;li&gt;The equivalent of &lt;strong&gt;VFX Graph&lt;/strong&gt; is &lt;strong&gt;GPU-compute particles built with TSL compute on the WebGPU renderer&lt;/strong&gt; — the technique, not the language.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The strategic difference today: &lt;strong&gt;three.js is well ahead on WebGPU&lt;/strong&gt; (its &lt;code&gt;WebGPURenderer&lt;/code&gt; + TSL are production-usable, and WebGPU browser support is real and growing), so you &lt;em&gt;can&lt;/em&gt; ship GPU-compute particles there if you target WebGPU. &lt;strong&gt;Unity’s web export is still WebGL2/GLES3&lt;/strong&gt; (its WebGPU backend is experimental), so for a shipping Unity web game you assume &lt;strong&gt;no compute → no VFX Graph&lt;/strong&gt; , and CPU particles + standard shaders are the lane. The “realistic GPU fireworks” I wanted would be on the table in three.js-on-WebGPU; on Unity-on-WebGL2, the boring CPU &lt;code&gt;ParticleSystem&lt;/code&gt; was the right — and only — answer.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test in the actual build — every time.&lt;/strong&gt; The editor renders through a different backend (Metal/D3D), often a different quality tier, and never strips shaders. &lt;em&gt;Every&lt;/em&gt; wrong diagnosis came from trusting the editor or my own reasoning; &lt;em&gt;every&lt;/em&gt; correction came from the build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invisible particles have several unrelated causes&lt;/strong&gt; — shader-variant stripping, Soft Particles (no depth texture), and particle-system quirks all produce the same “spawns, on-screen, invisible.” Don’t pattern-match the second case to the first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soft Particles need the depth texture&lt;/strong&gt; the WebGL tier doesn’t provide → silent fade to invisible. Turn them off, or enable Depth Texture (at the cost of a pass).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VFX Graph never ships to WebGL&lt;/strong&gt; (needs compute). &lt;strong&gt;Shader Graph usually does&lt;/strong&gt; — verify in build; don’t assume it’s “black on GLES3.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Supports URP” ≠ “supports WebGL.”&lt;/strong&gt; Platform, not pipeline, is the constraint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know when to stop.&lt;/strong&gt; I never fully pinned &lt;em&gt;why&lt;/em&gt; the CFXR firework burst was invisible here (CFXR otherwise renders fine on WebGL — the chest glow proves it). Once a reliable alternative worked, the forensics weren’t worth it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stay humble about confident diagnoses.&lt;/strong&gt; I had three airtight ones. A 30-second in-build A/B test beat all of them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The firework glows now — a CPU particle system from decade-old tech, lit by a renderer-feature glow, rendering happily in a browser. Sometimes the boring tool is the right tool. And the fastest debugger is often two objects side by side in the actual build.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/unity-webgl-particles-invisible-casuses-and-how-to-fix-it/" rel="noopener noreferrer"&gt;Unity WebGL Particles Invisible? Causes and How to Fix It&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>shader</category>
      <category>webgl</category>
      <category>threejs</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Building MESH: A Civic Resilience Platform With AI Agents</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 23 May 2026 08:27:29 +0000</pubDate>
      <link>https://dev.to/furic/building-mesh-a-civic-resilience-platform-with-ai-agents-15jf</link>
      <guid>https://dev.to/furic/building-mesh-a-civic-resilience-platform-with-ai-agents-15jf</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsnposaatcybyk1cjzfi7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsnposaatcybyk1cjzfi7.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A one-day hackathon project at the Claude Impact Lab (Melbourne, May 2026). The brief was open. I built MESH — &lt;em&gt;Melbourne Exchange &amp;amp; Solidarity Hub&lt;/em&gt; — a gamified civic platform where residents see their suburb as a node in a network and AI agents read live open data to suggest the most useful thing the community can do next.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9eu49lxaww7b3fm2hcnq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9eu49lxaww7b3fm2hcnq.jpg" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Why MESH
&lt;/h2&gt;

&lt;p&gt;Every Australian suburb is sitting on quiet abundance. Gardens that produce too much. Retirees who could teach welding. Neighbours who’d happily check on each other in a heatwave if anyone asked. The capacity exists. The wiring doesn’t.&lt;/p&gt;

&lt;p&gt;Three observations led me to MESH:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Civic capacity is fragmented.&lt;/strong&gt; There’s no shared map of who can teach what, who has spare zucchini, who’s ready in a heatwave. The information lives in group chats, in heads, in council PDFs nobody reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open data is inert.&lt;/strong&gt; &lt;code&gt;data.melbourne.vic.gov.au&lt;/code&gt; alone publishes 239 datasets. Almost none of them inform a decision a resident will make this week. The data isn’t missing — the &lt;em&gt;loop&lt;/em&gt; is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The unit of resilience is the suburb.&lt;/strong&gt; “City-wide” is too coarse to coordinate. “Neighbourhood” is too granular to measure. The 3-km suburb — Carlton, Footscray, Brunswick — is the natural unit. So MESH scores, narrates, and gamifies at that scale.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The vision: each Melbourne suburb gets a &lt;strong&gt;resilience index&lt;/strong&gt; (R-index) derived from five pillars — food security, skill density, resource sharing, social connectivity, emergency preparedness. AI agents read each suburb’s data and suggest the most impactful initiative residents could run &lt;em&gt;this week&lt;/em&gt;. Completing it earns XP. The suburb’s score moves. The board updates. Other residents see it. It is meant to feel like a slow-motion multiplayer game.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. What it does — a five-minute tour
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The map
&lt;/h3&gt;

&lt;p&gt;The home page is the centrepiece. A real Google Maps WebGL view of inner Melbourne with five suburb polygons (OSM-sourced) colour-coded by R-index. Clicking a suburb (or a sidebar row) tilts the camera to 2.5D, zooms in, and reveals 3D building extrusion native to the basemap. A top-r_index suburb gets a permanent pulsing golden ring; every selection fires a screen-projected burst animation; flowing dots travel along the inter-suburb lines as a stand-in for &lt;em&gt;potential exchange capacity&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A small &lt;strong&gt;(i) provenance icon&lt;/strong&gt; next to the suburb name expands a panel showing exactly where that suburb’s data came from — real / partial / SEIFA-approximated — with links to each underlying dataset. The detail card also embeds a live &lt;strong&gt;advisor chat&lt;/strong&gt; that streams Claude responses contextualised to the selected suburb.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuoi994dwipix01zg50bq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuoi994dwipix01zg50bq.jpg" alt="Streaming advisor chat for Carlton" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The quest board
&lt;/h3&gt;

&lt;p&gt;Five hand-curated &lt;strong&gt;EXAMPLE&lt;/strong&gt; quests load on first paint (one per suburb, each targeting that suburb’s weakest pillar). A single click — &lt;em&gt;“Regenerate with Claude →”&lt;/em&gt; — replaces the seed with a fresh AI-generated quest, marked &lt;strong&gt;AI&lt;/strong&gt; , that persists to &lt;code&gt;localStorage&lt;/code&gt; so it survives a refresh. Quests generated from the advisor chat appear here too — the two surfaces share state.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4qiapx606ababbik252s.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4qiapx606ababbik252s.jpg" alt="Quest board with seeded + AI-generated cards" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The pitch + the admin surface
&lt;/h3&gt;

&lt;p&gt;There’s also &lt;code&gt;/pitch&lt;/code&gt; (a long-form editorial walkthrough I built for the hackathon judging panel — Fraunces display type, scroll-paced narrative covering vision, pillars, the data → quest → XP loop, the six agents, honest data provenance, the stack, the roadmap) and &lt;code&gt;/admin&lt;/code&gt; (gated on &lt;code&gt;profile.is_admin === true&lt;/code&gt;, used to bulk-regenerate every quest with Claude in front of a live audience).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/login&lt;/code&gt; has demo personas — pick Maya from Carlton, Tom from Brunswick, Sofia from Footscray, the Admin operator, or play yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Real data, transparent AI
&lt;/h2&gt;

&lt;p&gt;This is the bit I’m most proud of.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real open data with honest provenance
&lt;/h3&gt;

&lt;p&gt;The five suburb pillar scores aren’t made up. They’re pulled by &lt;code&gt;scripts/seed-suburbs.ts&lt;/code&gt; from three live Melbourne open-data feeds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;social-indicators-for-city-of-melbourne-residents-2023&lt;/code&gt; (City of Melbourne resident survey)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;landmarks-and-places-of-interest&lt;/code&gt; (community-facility density within 1.5 km of each centroid)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pedestrian-counting-system-monthly-counts-per-hour&lt;/code&gt; (foot traffic → social-connectivity proxy)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus &lt;strong&gt;ABS 2021 SEIFA IRSD deciles&lt;/strong&gt; as the fallback for any pillar without a direct signal, and OSM via Nominatim for the boundary polygons.&lt;/p&gt;

&lt;p&gt;The catch: &lt;code&gt;data.melbourne.vic.gov.au&lt;/code&gt; only covers the City of Melbourne LGA. So of my five demo suburbs, only Carlton has full real data. Fitzroy gets partial coverage (sensors and landmarks reach across the LGA border). Brunswick, Footscray, and Richmond — different LGAs — fall back to SEIFA approximations.&lt;/p&gt;

&lt;p&gt;Rather than hide that, I made it the brand. Every suburb’s detail card shows a &lt;code&gt;real&lt;/code&gt; / &lt;code&gt;partial&lt;/code&gt; / &lt;code&gt;seifa&lt;/code&gt; chip with links to the exact datasets used. The &lt;code&gt;/pitch&lt;/code&gt; page has a full provenance table. The &lt;code&gt;mock-suburbs.ts&lt;/code&gt; file has a per-suburb comment header showing the derivation for the most recent seed run. The schema even has a &lt;code&gt;data_source&lt;/code&gt; column on the &lt;code&gt;suburbs&lt;/code&gt; table to enforce this at the database level.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The “we tell you what’s real” framing is more interesting than any made-up score.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Glass-box AI
&lt;/h3&gt;

&lt;p&gt;There are six agents in the design (see &lt;a href="https://github.com/furic/mesh/blob/main/AGENTS.md" rel="noopener noreferrer"&gt;AGENTS.md&lt;/a&gt;); three are wired end-to-end today: &lt;strong&gt;Quest Generator&lt;/strong&gt; (returns one structured JSON quest for a suburb), &lt;strong&gt;Initiative Advisor&lt;/strong&gt; (streaming SSE chat grounded in the selected suburb’s context), and &lt;strong&gt;Suburb Narrator&lt;/strong&gt; (weekly plain-English digest).&lt;/p&gt;

&lt;p&gt;Every AI quest card surfaces &lt;em&gt;why&lt;/em&gt; it exists, in two layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always visible — the signal strip&lt;/strong&gt; (amber left-border, between badges and description):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SIGNAL · Claude targeted &lt;strong&gt;food security&lt;/strong&gt; because Carlton scored &lt;strong&gt;27/100&lt;/strong&gt; — the lowest of its five pillars.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the smallest possible “data → AI choice” lineage: one sentence, one number. The audience sees in plain English which pillar was lowest and why the agent picked it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expandable — “How this was created”:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model name (&lt;code&gt;claude-sonnet-4-20250514&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Generation timestamp&lt;/li&gt;
&lt;li&gt;The full input the model saw (suburb · SEIFA · population · season)&lt;/li&gt;
&lt;li&gt;The weakest pillar + score (what triggered the choice)&lt;/li&gt;
&lt;li&gt;All five pillar scores at generation time as F/S/R/C/E chips&lt;/li&gt;
&lt;li&gt;A link to &lt;code&gt;/pitch#provenance&lt;/code&gt; for the underlying datasets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent endpoint at &lt;code&gt;src/routes/api/quests/+server.ts&lt;/code&gt; returns the &lt;code&gt;data_snapshot&lt;/code&gt; alongside the quest, the snapshot persists with the quest in &lt;code&gt;localStorage&lt;/code&gt;, so refreshing keeps the panel populated. The DB schema already has a &lt;code&gt;data_snapshot jsonb&lt;/code&gt; column on the &lt;code&gt;quests&lt;/code&gt; table — when Supabase comes online the persistence layer is already aligned.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No vibe-based moderation. Every AI decision is auditable in the UI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The output discipline is strict: all JSON-returning agents end their system prompt with &lt;code&gt;OUTPUT: Return ONLY valid JSON.&lt;/code&gt; and route raw output through a &lt;code&gt;parseAgentJSON()&lt;/code&gt; helper that strips accidental &lt;code&gt;json&lt;/code&gt; fences before &lt;code&gt;JSON.parse&lt;/code&gt;. The advisor is streamed without parsing — its SSE response body pipes straight to the browser. Cost guardrails baked in: max one quest generation per suburb per 24h, narratives cached for 7 days, Matchmaker skipped when the radius is too sparse.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The stack, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt; — SvelteKit 2 + Svelte 5 with runes mode + TypeScript strict. Svelte 5’s &lt;code&gt;$state&lt;/code&gt; / &lt;code&gt;$effect&lt;/code&gt; / &lt;code&gt;$derived&lt;/code&gt; runes made the reactive flow — store hydration from &lt;code&gt;localStorage&lt;/code&gt;, runtime feature-state toggles on the map, the XP-burst on level-up — read cleanly. No external state library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map&lt;/strong&gt; — Google Maps Platform’s WebGL vector renderer with a Map ID configured for tilt + rotation. Pitch 67.5° + heading 20° gives cohesive 2.5D with native 3D building extrusion in the CBD. Polygon overlays sit at zIndex 1000 above the fills; flowing dots are &lt;code&gt;google.maps.Polyline&lt;/code&gt; icons stepped via RAF. I tried MapLibre first; it works but the basemap stays flat and our 3D extrusions float — Google’s WebGL vector is the only path to coherent tilt-with-buildings without a custom tile pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI&lt;/strong&gt; — Anthropic Claude (&lt;code&gt;claude-sonnet-4-20250514&lt;/code&gt;) called directly from SvelteKit endpoints (streaming SSE for the advisor; JSON-mode for everything else). Cost-guarded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open data&lt;/strong&gt; — &lt;code&gt;data.melbourne.vic.gov.au&lt;/code&gt; (Opendatasoft v2.1, anonymous reads) + OpenStreetMap via Nominatim. A one-time fetch script (&lt;code&gt;pnpm fetch:suburb-geo&lt;/code&gt;) writes polygon boundaries to a committed JSON file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt; — Supabase Postgres 15 + PostGIS. Schema + RLS policies fully written (13 SQL migrations) and the typed &lt;code&gt;SupabaseClient&amp;lt;Database&amp;gt;&lt;/code&gt; is wired into a per-request server client in &lt;code&gt;hooks.server.ts&lt;/code&gt; with a typed-Proxy fallback when env is unconfigured. Provisioning a real Supabase project is one user action away; everything else is offline-complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting&lt;/strong&gt; — Vercel via &lt;code&gt;@sveltejs/adapter-vercel&lt;/code&gt;, auto-deploy on push to &lt;code&gt;main&lt;/code&gt;. Preview deploys per PR.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  5. What’s next
&lt;/h2&gt;

&lt;p&gt;MESH is a 10-sprint roadmap; this hackathon got me to roughly sprint 4. The remaining headline pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sprint 7&lt;/strong&gt; — Submission Verifier. The agent that closes the loop: residents submit photo + text evidence of a completed quest, Claude vision verifies, XP is awarded or routed to a moderation queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sprint 8&lt;/strong&gt; — Resource Matchmaker. The agent that powers the “Exchange” in the name. Posts of “I have 10 kg of zucchini” get semantically matched to “I need vegetables for a community kitchen” within a 10 km PostGIS &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; radius, and the mesh edges between matched suburbs light up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sprint 10&lt;/strong&gt; — Anomaly Watcher. Watches each open-data sync for deltas, classifies them as opportunity / risk / info, and spawns reactive quests or alerts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The architecture is already shaped to land them quickly once the Supabase project is provisioned. The data-snapshot + transparency pattern I built into the Quest Generator transfers directly to each new agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo →&lt;/strong&gt; &lt;a href="https://mesh-pi-topaz.vercel.app/" rel="noopener noreferrer"&gt;https://mesh-pi-topaz.vercel.app/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source →&lt;/strong&gt; &lt;a href="https://github.com/furic/mesh" rel="noopener noreferrer"&gt;https://github.com/furic/mesh&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pitch deck-as-a-page →&lt;/strong&gt; &lt;a href="https://mesh-pi-topaz.vercel.app/pitch" rel="noopener noreferrer"&gt;https://mesh-pi-topaz.vercel.app/pitch&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few things worth clicking:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign in as &lt;strong&gt;Admin · Demo&lt;/strong&gt; on &lt;code&gt;/login&lt;/code&gt;, then hit &lt;strong&gt;“Generate all 5 with Claude →”&lt;/strong&gt; on &lt;code&gt;/admin&lt;/code&gt;. Watch the EXAMPLE chips flip to AI in real time.&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;/quests&lt;/code&gt;, expand any AI quest’s &lt;em&gt;“How this was created”&lt;/em&gt; — the full agent lineage is right there.&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;/&lt;/code&gt;, click the &lt;strong&gt;(i)&lt;/strong&gt; next to any suburb name to see exactly which datasets fed its scores.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Built in a day for the &lt;strong&gt;Claude Impact Lab&lt;/strong&gt; hackathon (Melbourne, 23 May 2026). Coffee was consumed. Polygons were fetched. AI rationales were strict-JSON-fenced. The streets of Carlton tilted on cue.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/mesh-civic-resilience-platform-claude-ai/" rel="noopener noreferrer"&gt;Building MESH: A Civic Resilience Platform With AI Agents&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>svelte</category>
      <category>webdev</category>
      <category>hackathon</category>
    </item>
    <item>
      <title>Richfolio, three months in: AI architecture in production</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Fri, 22 May 2026 10:14:46 +0000</pubDate>
      <link>https://dev.to/furic/richfolio-three-months-in-ai-architecture-in-production-13il</link>
      <guid>https://dev.to/furic/richfolio-three-months-in-ai-architecture-in-production-13il</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1l58uzy1yr5dlttzc3xw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1l58uzy1yr5dlttzc3xw.png" alt=" " width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;From v1.0 to v1.6 — what I rebuilt, what I borrowed, and what’s running in production for $0/month&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three months ago I shipped v1.0 of &lt;a href="https://github.com/furic/richfolio" rel="noopener noreferrer"&gt;Richfolio&lt;/a&gt; — a zero-maintenance side project that emails me a daily AI-powered portfolio digest. I wrote about the original motivation and v1 architecture in &lt;a href="https://dev.to/furic/i-built-a-free-ai-portfolio-assistant-that-emails-me-every-morning-3plm"&gt;this earlier post&lt;/a&gt; if you want the background.&lt;/p&gt;

&lt;p&gt;Since then it’s gone through six minor releases. The code roughly tripled in size, the AI architecture got rebuilt from the ground up to handle production use, and I’ve been using it on my own portfolio daily. This post is a project-level update: where Richfolio is, what stack it runs on, the architectural patterns I borrowed (mostly from &lt;a href="https://github.com/TraderAlice/OpenAlice" rel="noopener noreferrer"&gt;OpenAlice&lt;/a&gt;), and one concrete case study showing how those patterns paid off in v1.6.&lt;/p&gt;

&lt;p&gt;At the end I’m looking for alpha testers, so if any of this is interesting and you’ve got a portfolio that doesn’t look like mine, keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Richfolio is now
&lt;/h2&gt;

&lt;p&gt;A single Node.js + TypeScript pipeline, no API server, no dashboard. Runs as a GitHub Actions cron job (8am AEST). Four modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Daily&lt;/strong&gt; — full analysis + email + Telegram&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intraday&lt;/strong&gt; — periodic STRONG BUY change alerts (only fires on real signal changes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weekly&lt;/strong&gt; — rebalancing drift report&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh&lt;/strong&gt; — re-analyze a single ticker with after-hours prices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Output looks like this: a dark-themed HTML email per morning, condensed Telegram message in parallel, and for every STRONG BUY ticker, a “More Details” link to a dedicated analysis page on GitHub Pages with an interactive TradingView chart, AI-generated buy thesis, and risk analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack — still $0/month
&lt;/h2&gt;

&lt;p&gt;Everything Richfolio runs on is free-tier:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Free tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prices, fundamentals, technicals&lt;/td&gt;
&lt;td&gt;Yahoo Finance via &lt;code&gt;yahoo-finance2&lt;/code&gt; v3&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;News&lt;/td&gt;
&lt;td&gt;NewsAPI.org&lt;/td&gt;
&lt;td&gt;100 req/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI analysis&lt;/td&gt;
&lt;td&gt;Google Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;250 req/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email delivery&lt;/td&gt;
&lt;td&gt;Resend.com&lt;/td&gt;
&lt;td&gt;3,000/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push notification&lt;/td&gt;
&lt;td&gt;Telegram Bot API&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduler&lt;/td&gt;
&lt;td&gt;GitHub Actions cron&lt;/td&gt;
&lt;td&gt;2,000 min/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static hosting&lt;/td&gt;
&lt;td&gt;GitHub Pages&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State persistence&lt;/td&gt;
&lt;td&gt;&lt;code&gt;actions/cache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No database. No queue. No server. State is a JSON file in the repo that survives cron runs via &lt;code&gt;actions/cache&lt;/code&gt;. The full cost ceiling for a single user is the Resend tier — and Richfolio sends maybe 60 emails a month, so there’s headroom for ~50 users on a single account.&lt;/p&gt;

&lt;p&gt;The interesting constraint isn’t the budget — it’s the request budget. Gemini’s 250/day cap means every “let me ask the AI” feature has to be designed around batch calls, not per-ticker calls. That shaped a lot of the architecture below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: patterns I borrowed from OpenAlice
&lt;/h2&gt;

&lt;p&gt;The biggest single influence on Richfolio’s post-v1 evolution was &lt;a href="https://github.com/TraderAlice/OpenAlice" rel="noopener noreferrer"&gt;OpenAlice&lt;/a&gt; — an autonomous AI trading agent from TraderAlice. OpenAlice is much bigger than Richfolio (multi-broker, multi-asset, with a UI) but its cognitive architecture transferred cleanly. Four patterns in particular.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Two-stage Think/Plan AI prompting
&lt;/h3&gt;

&lt;p&gt;The v1 pipeline used a single Gemini call: shove everything into one prompt, parse one JSON response. The problem: when the AI conflates data parsing with decision-making, it hallucinates support for whatever conclusion it’s already started writing. STRONG BUY false positives crept in monthly.&lt;/p&gt;

&lt;p&gt;The fix was to split the call into two stages, mirroring OpenAlice’s &lt;code&gt;think&lt;/code&gt; and &lt;code&gt;plan&lt;/code&gt; tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1 (Observe)&lt;/strong&gt; — Gemini gets raw data and produces &lt;em&gt;structured observations only&lt;/em&gt;: which price-level signals are present, which momentum signals, which risk flags, a one-line valuation summary, a one-line technical summary, news sentiment. No actions, no recommendations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 2 (Decide)&lt;/strong&gt; — Gemini gets the observations and applies strict decision rules to produce ranked recommendations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two API calls instead of one — wasteful in theory, but still 2/250 of the daily quota. The practical win is significant: forcing the AI to commit to what it &lt;em&gt;sees&lt;/em&gt; before deciding what to &lt;em&gt;do&lt;/em&gt; dropped false-positive STRONG BUYs to near zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Post-AI guard pipeline
&lt;/h3&gt;

&lt;p&gt;The other half of trustworthy AI output is &lt;em&gt;not trusting the AI&lt;/em&gt;. OpenAlice’s &lt;code&gt;guard-pipeline.ts&lt;/code&gt; runs sequential validation between the AI’s proposed trades and the broker. Richfolio adapts the same pattern in &lt;code&gt;guards.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateRecommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AIBuyRecommendation&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;priceData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;QuoteData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;technicals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TechnicalData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AllocationReport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;guardBondETFCap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardEarningsProximity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priceData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardStrongBuyCriteria&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;technicals&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardMaxStrongBuy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardConfidenceSanity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;guardBuyValueSanity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Six sequential checks that programmatically enforce the rules I told the AI to follow. Each guard logs when it fires, so I can see which AI mistakes were caught downstream — useful signal when tuning prompts.&lt;/p&gt;

&lt;p&gt;The architectural pattern — &lt;em&gt;let the AI propose freely, validate deterministically afterward&lt;/em&gt; — is the single most useful idea I borrowed from OpenAlice. It makes prompt experiments low-risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Seven-day reasoning persistence
&lt;/h3&gt;

&lt;p&gt;OpenAlice has a &lt;code&gt;Brain&lt;/code&gt; module that tracks cognitive state across sessions via Git-like commits. Richfolio doesn’t need that level of machinery, but the principle — &lt;em&gt;let the AI see its own past conclusions&lt;/em&gt; — adapts cleanly to a 7-day rolling history of AI snapshots.&lt;/p&gt;

&lt;p&gt;Every decision prompt now receives a “HISTORICAL CONTEXT” block like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
AAPL: BUY 72% → BUY 68% → HOLD 55% — weakening
NVDA: STRONG BUY 88% → STRONG BUY 91% → STRONG BUY 89% — stable
BSV: BUY 75% → BUY 75% → BUY 75% — flat

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

&lt;/div&gt;



&lt;p&gt;(More on that last line in a minute.)&lt;/p&gt;

&lt;p&gt;The history forces the AI to acknowledge trend changes. Without it, the model would sometimes flip a ticker from BUY 70% to HOLD 50% on consecutive days with no acknowledgement — which feels wrong even if individually justified.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Macro environment context
&lt;/h3&gt;

&lt;p&gt;Yahoo Finance is free, and &lt;code&gt;yahooFinance.quoteSummary('^VIX')&lt;/code&gt; works the same as it does for any equity. So every Richfolio run now fetches VIX, the 10-year Treasury yield, S&amp;amp;P 500, oil (WTI), and the USD index (DXY) — five extra calls — and pipes them as a &lt;code&gt;MACRO ENVIRONMENT:&lt;/code&gt; preamble into both the observation and decision prompts.&lt;/p&gt;

&lt;p&gt;This is OpenAlice’s “equity research” influence translated to a portfolio context. The macro block lets Gemini write things like &lt;em&gt;“elevated VIX + high yields suggest defensive positioning”&lt;/em&gt; instead of generic boilerplate, and gates STRONG BUYs more conservatively in high-volatility environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case study: fixing the BSV “75% BUY every day” problem
&lt;/h2&gt;

&lt;p&gt;The patterns above sound abstract. v1.6 was a concrete test of whether they paid off.&lt;/p&gt;

&lt;p&gt;Since v1.4 (which introduced the bond ETF framework back in early April), my morning email had this same line every single day:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;BSV&lt;/strong&gt; — 75% BUY — &lt;em&gt;Systematic accumulation to fill 5% allocation gap.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reason was an over-rigid prompt rule. BSV is a short-duration bond ETF — it has a ~2% annual price range, and equity-style signals like RSI and MACD are noise on a security that barely moves. So I’d hard-coded a confidence formula by allocation gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Gap ≥ 5%: confidence 70-75%
Gap 3-5%: confidence 60-70%
Gap 1-3%: confidence 45-55%
Gap &amp;amp;lt; 1%: HOLD

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

&lt;/div&gt;



&lt;p&gt;That removed false momentum signals but flatlined the output. With a steady ~5% gap, the answer was 75% every day forever.&lt;/p&gt;

&lt;p&gt;The fix needed &lt;em&gt;some&lt;/em&gt; timing signal. Just not equity timing signals. After thinking about what actually matters when buying bond ETFs, three orthogonal signals fell out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Price percentile in a rolling 90-day window.&lt;/strong&gt; Where today’s price sits in BSV’s recent range. Computed from existing chart data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pricePercentile90d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;last90&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;min90&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;last90&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;max90&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;last90&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max90&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;min90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pricePercentile90d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="nx"&gt;currentPrice&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;min90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max90&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;min90&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. 10-year Treasury yield direction.&lt;/strong&gt; Bonds get cheaper when rates rise. I added a 20-trading-day delta on the existing macro fetch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;treasury10y&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;indicators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;treasury10y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;period1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;period1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;period1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;yahooFinance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;period1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;period2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;closes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quotes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;indicators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;treasury10yChange20d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;closes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Distribution yield level.&lt;/strong&gt; Yahoo’s &lt;code&gt;summaryDetail.yield&lt;/code&gt; returns the SEC / 12-month yield for funds. High yield = high income premium.&lt;/p&gt;

&lt;p&gt;Then a new prompt framework that scores via &lt;strong&gt;base + timing modifiers&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
12a. SHORT-DURATION BOND ETFs:
  BASE (from gap): 5%→55, 3-5%→45, 1-3%→35, &amp;amp;lt;1%→HOLD
  + 90d percentile: ≤20% +12, ≥80% -15
  + 10Y 20d change: &amp;gt;+0.15% +6, &amp;amp;lt;-0.15% -12
  + Yield: &amp;gt;4.5% +3, &amp;amp;lt;3% -2

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

&lt;/div&gt;



&lt;p&gt;On a great day for BSV — near 90-day low, rates spiking, high yield, big gap — confidence reaches ~90. On a bad day — top of range, rates falling — it drops to ~25. Real day-to-day variance.&lt;/p&gt;

&lt;p&gt;This is where the architectural patterns paid off. The bond ETF rewrite is exactly the kind of change I’d previously been nervous to ship — the AI could ignore the new modifiers and hallucinate a STRONG BUY on BSV, which I never want. But the guard pipeline catches it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SHORT_DURATION_BOND_ETFS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRONG BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suggestedLimitPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suggestedLimitPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suggestedLimitPrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limitPriceReason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;And the action-tier sort (new in v1.6) means even if BSV’s confidence reaches 90 on a great day, equity STRONG BUYs still rank above it visually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ACTION_PRIORITY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRONG BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;BUY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;HOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;WAIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;recommendations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ACTION_PRIORITY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ACTION_PRIORITY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The whole BSV fix was about 200 lines of code across four files. The reason it was easy: the guards layer meant I could trust prompt changes to fail safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other things shipped since v1.0
&lt;/h2&gt;

&lt;p&gt;Beyond the architecture and the bond ETF case study, a lot of smaller features landed across v1.1 through v1.6:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Earnings calendar guard&lt;/strong&gt; — Yahoo’s &lt;code&gt;calendarEvents&lt;/code&gt; module ships next-earnings date inside the existing &lt;code&gt;quoteSummary&lt;/code&gt; call. Hard HOLD ≤3 days to earnings, no STRONG BUY ≤7.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;News sentiment scoring&lt;/strong&gt; — every headline tagged bullish/bearish/neutral with high/medium/low impact, in the same Gemini call that was already filtering relevance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Technical indicators expanded&lt;/strong&gt; — beyond SMA/RSI from v1: MACD with crossover detection, Bollinger Bands with squeeze detection, ATR, Stochastic, OBV trend. All from existing chart data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value investing framework&lt;/strong&gt; — A–D fundamental ratings for individual stocks based on ROE, debt/equity, FCF, growth, analyst target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crypto bottom-fishing model&lt;/strong&gt; — RSI + volume contraction + 200MA + death cross confluence detection for BTC/ETH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STRONG BUY analysis pages&lt;/strong&gt; — interactive TradingView chart + buy thesis + risk analysis per STRONG BUY ticker, all encoded in the URL hash (no server).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh mode&lt;/strong&gt; — re-analyze a single ticker with after-hours / pre-market price for late-night decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intraday alerts&lt;/strong&gt; — only fire on STRONG BUY changes (upgrade, downgrade, or ≥10pt confidence shift). No noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;International currency support&lt;/strong&gt; — non-USD portfolios fully supported with live FX from Yahoo. Sub-unit handling for exchanges quoting in pence/agorot/cents (LSE GBp → GBP ÷100).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI + tests&lt;/strong&gt; — 63 unit tests via &lt;code&gt;node:test&lt;/code&gt;, zero dependencies, runs on every PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18n docs&lt;/strong&gt; — full English / Simplified Chinese / Traditional Chinese site via Jekyll polyglot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full release notes are on &lt;a href="https://github.com/furic/richfolio/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest results so far
&lt;/h2&gt;

&lt;p&gt;I’ve been running Richfolio against my own portfolio for the past few months. Across that window I’m up roughly &lt;strong&gt;6%&lt;/strong&gt; from acting on the AI’s STRONG BUY suggestions.&lt;/p&gt;

&lt;p&gt;The caveat I have to put in writing: US markets have been strong over the same window. A meaningful chunk of that 6% is probably tailwind. I can’t say &lt;em&gt;“the AI added 6%”&lt;/em&gt; — I can only say &lt;em&gt;“following the AI’s suggestions during a strong market gave me 6% on those entries.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To know whether Richfolio actually adds value vs riding the tape, I need to see it run against portfolios that look nothing like mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alpha testers wanted
&lt;/h2&gt;

&lt;p&gt;If you’d like to point Richfolio at your portfolio for a few weeks and tell me where it’s wrong, I’d love to hear from you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I’d love testers to bring:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A portfolio that looks different from mine — heavy crypto, sector-concentrated, dividend-focused, international, REIT-heavy, small-cap heavy&lt;/li&gt;
&lt;li&gt;Honesty about whether the STRONG BUY signal actually matches what you’d buy yourself&lt;/li&gt;
&lt;li&gt;Bug reports — weird tickers, exchanges I don’t trade on, edge cases in the FX layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A working free-tier portfolio monitor&lt;/li&gt;
&lt;li&gt;A 1-on-1 setup walkthrough&lt;/li&gt;
&lt;li&gt;Direct line to me for feedback and feature requests&lt;/li&gt;
&lt;li&gt;First look at upcoming versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setup is a GitHub fork plus a few environment variables — full docs at &lt;a href="https://furic.github.io/richfolio" rel="noopener noreferrer"&gt;furic.github.io/richfolio&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;DM me or open an issue on the repo if you’re interested.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/richfolio-three-months-in-ai-architecture-in-production/" rel="noopener noreferrer"&gt;Richfolio, three months in: AI architecture in production&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>finance</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Unity WebGL Safari Hang: The First-Draw Shader Stall</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 16 May 2026 07:55:26 +0000</pubDate>
      <link>https://dev.to/furic/unity-webgl-safari-hang-the-first-draw-shader-stall-43f2</link>
      <guid>https://dev.to/furic/unity-webgl-safari-hang-the-first-draw-shader-stall-43f2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1llimdqp4fr03qyf1hz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1llimdqp4fr03qyf1hz.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — This is a war story about a &lt;strong&gt;Unity WebGL Safari hang&lt;/strong&gt; : a game that ran beautifully on Chrome but froze for ~3 seconds on the first major animation event in iOS Safari. After a long detour through material instancing, shader keywords, animator transitions, and post-FX, the culprit was a single prefab nobody thought to warm up: an overlay spawned by an obscure event handler one frame before the animation started. &lt;strong&gt;Mid-game &lt;code&gt;Instantiate&lt;/code&gt; of a prefab with a custom shader triggers Safari’s &lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt; on first render — synchronous, main-thread, ~3ms per uniform lookup, ~200 uniforms across a complex prefab = 2.8s stall.&lt;/strong&gt; The fix is to render the prefab once during the splash so the cache is warm before gameplay needs it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reproducing the Unity WebGL Safari Hang
&lt;/h2&gt;

&lt;p&gt;I’m creating a Unity WebGL game with the Universal Render Pipeline (URP) and a lot of custom shaders — particle bursts, dissolve effects, glow overlays, 3D character animations, the works. The target audience is mobile; the majority of players are on iOS Safari, which is exactly where the Unity WebGL Safari hang showed up.&lt;/p&gt;

&lt;p&gt;Everything tested fine on desktop Chrome — silky 60fps, fast first interaction, no hitches.&lt;/p&gt;

&lt;p&gt;Then we tried Safari on a Mac (and later an iPhone 12). The &lt;strong&gt;first major animation event&lt;/strong&gt; of every session froze the entire game for ~3 seconds. The freeze happened &lt;em&gt;after&lt;/em&gt; the build-up animation completed but &lt;em&gt;before&lt;/em&gt; the payoff sequence visually started. Every subsequent occurrence of the same event was perfectly smooth.&lt;/p&gt;

&lt;p&gt;The pattern was textbook “first-use compile stall”:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;Hang on first event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome (normal)&lt;/td&gt;
&lt;td&gt;~0.1s (barely perceptible)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome (incognito, no GPU cache)&lt;/td&gt;
&lt;td&gt;~0.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;~2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari (macOS)&lt;/td&gt;
&lt;td&gt;~3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 12 Safari&lt;/td&gt;
&lt;td&gt;~3s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 25× delta between Chrome and Safari is the smoking gun. Both browsers run Metal under the hood on macOS/iOS — Chrome via ANGLE, Safari directly — but ANGLE aggressively caches shader compilation work that Safari’s WebGL→Metal translator does over and over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong rabbit holes
&lt;/h2&gt;

&lt;p&gt;Before finding the real cause, we eliminated a long list of suspects through targeted A/B tests. Each took a rebuild + Safari test cycle. Documenting them here in case anyone hits a similar pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A shader keyword toggle.&lt;/strong&gt; Our main mesh shader uses a &lt;code&gt;[Toggle(_USE_DISSOLVE)]&lt;/code&gt; property that activates a fragment-shader branch. Disabling the keyword → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;renderer.material = newInstance&lt;/code&gt; assignment.&lt;/strong&gt; Skipped it on 13 simultaneous objects → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An animator state transition.&lt;/strong&gt; Commented out the state change that occurred on the same frame → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The entire state handler for the suspect state.&lt;/strong&gt; Stubbed out the whole switch case → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MMFeedbacks / Feel post-FX (camera shake, vignette).&lt;/strong&gt; Unsubscribed the handler → hang persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate fade-out coroutine on UI overlays.&lt;/strong&gt; Unsubscribed it → hang persisted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After all six A/Bs, we’d conclusively ruled out everything on the visible event path. The hang persisted even when the event handler did effectively nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic that cracked it
&lt;/h2&gt;

&lt;p&gt;Stopping the guess-and-check loop and capturing a &lt;strong&gt;Safari Web Inspector → Timelines&lt;/strong&gt; profile of the hang was the turning point.&lt;/p&gt;

&lt;p&gt;Inside the 3.1-second &lt;code&gt;Animation Frame Fired&lt;/code&gt; event, the breakdown was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
wasm-stub 1.94s
  _glGetUniformLocation 906ms (261 calls)
    webglPrepareUniformLocationsBeforeFi… 671ms (194 calls)
      getActiveUniform 444ms (128 calls)
      getProgramParameter 211ms (61 calls)
      getUniformLocation 201ms (58 calls)
  _glGetActiveUniform 423ms (122 calls)
  _glGetActiveUniformsiv 409ms (118 calls)
  _glGetActiveUniformBlockiv 191ms (55 calls)

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

&lt;/div&gt;



&lt;p&gt;The 1.94s is &lt;strong&gt;Unity’s WebGL runtime asking the browser to look up uniform locations&lt;/strong&gt; for shader programs it had never drawn before. The remaining ~1.2s of the frame is the actual Metal pipeline-state-object (PSO) compile triggered by the first draw call.&lt;/p&gt;

&lt;p&gt;Most importantly: &lt;strong&gt;194 distinct shader programs went through &lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt; in a single frame.&lt;/strong&gt; That’s not “one or two new variants” — that’s almost the entire scene’s shader inventory being prepared at once.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt; is WebKit-specific. On first draw of a program, it iterates every active uniform, queries the location for each, and caches the mapping. Each call into the WebGL JavaScript API costs ~3ms on Safari (vs ~0.1ms on Chrome’s ANGLE). 200 programs × ~10 calls each at 3ms ≈ 6 seconds of synchronous main-thread work. Spread that across however many programs need preparing on a given frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual root cause
&lt;/h2&gt;

&lt;p&gt;Once we knew it was first-draw uniform reflection, the question became &lt;em&gt;which prefab is first-drawn on that specific frame&lt;/em&gt;. The visible event handler had been stubbed out. Material instancing had been skipped. So what spawned at exactly the right moment?&lt;/p&gt;

&lt;p&gt;The game has a server-driven event sequence with several intermediate steps between the trigger and the visible animation. One of those intermediate steps was an “overlay reveal” — a manager that handles a separate server event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="n"&gt;GameObject&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Instantiate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overlayPrefab&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localPosition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;overlay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetComponent&lt;/span&gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="n"&gt;OverlayComponent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;A fresh &lt;code&gt;Instantiate&lt;/code&gt;, no pool. The overlay’s first draw call happens on the &lt;strong&gt;very next render frame&lt;/strong&gt; after spawn — which on this code path is &lt;strong&gt;frame #2 of the next animation’s wait window&lt;/strong&gt;. The hang at frame #2 matched perfectly. The overlay prefab uses a custom TMP text shader plus a particle/glow combo, with about 200 uniforms across its passes.&lt;/p&gt;

&lt;p&gt;Our shader warmup at boot covered the major VFX prefabs we knew about — the burst particles, the highlight glow, the popup labels, the dissolve variant of the main mesh shader. &lt;strong&gt;It did not cover this overlay&lt;/strong&gt; because the manager was an “old” engine system that pre-dated the warmup pattern. Nobody connected the dots: the overlay is spawned by a different event than the animation that hangs, so its first draw appears to be unrelated to the hang location.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the Unity WebGL Safari Hang
&lt;/h2&gt;

&lt;p&gt;A single line in the overlay manager’s &lt;code&gt;Awake&lt;/code&gt; resolved the Unity WebGL Safari hang completely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;MyGame.Rendering&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;virtual&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Awake&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...existing setup...&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overlayPrefab&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ShaderWarmup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WarmupPrefab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overlayPrefab&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ShaderWarmup.WarmupPrefab&lt;/code&gt; does the bare minimum needed to make WebKit compile the program and cache its uniform locations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Instantiates one copy of the prefab as a child of a &lt;code&gt;WarmupParent&lt;/code&gt; transform (parked at the gameplay anchor — wherever the real prefab will spawn in-game, so lighting matches)&lt;/li&gt;
&lt;li&gt;Plays any &lt;code&gt;ParticleSystem&lt;/code&gt; components so their geometry actually emits this frame&lt;/li&gt;
&lt;li&gt;Lets the &lt;strong&gt;main camera&lt;/strong&gt; render the warmup parent for 3 frames during the splash screen — same camera, same lighting state, same render-pass configuration as gameplay, so the PSO key matches&lt;/li&gt;
&lt;li&gt;Destroys the throwaway instance&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The web front-end’s splash screen waits for both &lt;code&gt;loadProgress &amp;gt;= 1&lt;/code&gt; AND a Unity-side &lt;code&gt;gameReady&lt;/code&gt; signal that only fires after every warmup pass has flushed. So the 1-3 seconds of pre-compile work happens behind the loading bar where players already expect to wait, instead of during a critical animation later.&lt;/p&gt;

&lt;p&gt;Result on Safari: &lt;strong&gt;first event dropped from 3.1s to ~500ms&lt;/strong&gt; — identical to every subsequent occurrence.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you need this — and when you don’t
&lt;/h2&gt;

&lt;p&gt;The combination of all three must be true:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Need warmup if…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lifecycle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Prefab is &lt;code&gt;Instantiate&lt;/code&gt;d at runtime (not present from scene load)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shader complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Uses non-trivial shader: custom &lt;code&gt;*.shader&lt;/code&gt;, &lt;code&gt;[Toggle(_KEYWORD)]&lt;/code&gt; properties, &lt;code&gt;multi_compile&lt;/code&gt; keywords, multi-pass shaders, particle shaders, or TMP shaders with &lt;code&gt;OUTLINE_ON&lt;/code&gt; / &lt;code&gt;UNDERLAY_ON&lt;/code&gt; / &lt;code&gt;BEVEL_ON&lt;/code&gt; variants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Timing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;First draw happens during gameplay, not during the loading splash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You &lt;strong&gt;don’t&lt;/strong&gt; need warmup for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Objects visible from scene load (Unity compiles their shaders during the splash render)&lt;/li&gt;
&lt;li&gt;Stock URP Lit/Unlit shaders with no keyword toggles&lt;/li&gt;
&lt;li&gt;UI Image components with the default UI shader&lt;/li&gt;
&lt;li&gt;Anything that doesn’t render (audio sources, logic GameObjects)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The diagnostic loop
&lt;/h2&gt;

&lt;p&gt;If you suspect this is happening in your game:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce on Safari&lt;/strong&gt; , ideally on real hardware. macOS Safari is usually enough — the WebGL-via-Metal path is the same.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Web Inspector → Timelines tab.&lt;/strong&gt; Hit record, trigger the event that hangs, stop recording.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the long frame&lt;/strong&gt; in the JavaScript &amp;amp; Events row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look under &lt;code&gt;wasm-stub&lt;/code&gt;&lt;/strong&gt; for high counts of &lt;code&gt;_glGetUniformLocation&lt;/code&gt;, &lt;code&gt;_glGetActiveUniform&lt;/code&gt;, &lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt;. If you see dozens or hundreds of these on one frame, you’ve found a first-use shader event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify what’s first-rendered.&lt;/strong&gt; What prefab spawned recently? What keyword was toggled? What material was instanced?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;ShaderWarmup.WarmupPrefab&lt;/code&gt; call&lt;/strong&gt; in that subsystem’s &lt;code&gt;Awake&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A subtle gotcha: the prefab that &lt;em&gt;causes&lt;/em&gt; the hang may not be the one whose animation is &lt;em&gt;running&lt;/em&gt; when the hang happens. In our case the overlay was spawned by a completely separate event handler one frame before the visible animation started, so the hang appeared to be inside the animation. Trace recent &lt;code&gt;Instantiate&lt;/code&gt; calls backward from the slow frame, not just what’s animating during it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related prior art
&lt;/h2&gt;

&lt;p&gt;The general “WebGL on Safari is slow at things ANGLE handles fast” story is well-documented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wonderlandengine.com/news/webgl-performance-safari-apple-vision-pro/" rel="noopener noreferrer"&gt;WebGL Performance on Safari and Apple Vision Pro — Wonderland Engine&lt;/a&gt; describes a ~150ms hitch from Safari’s Metal-emulated uniform buffer uploads. Same family of issues at a smaller scale.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bugs.webkit.org/show_bug.cgi?id=218949" rel="noopener noreferrer"&gt;WebKit Bug 218949&lt;/a&gt; — WebGL instanced draw calls slow on iPhone 12 Pro&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bugs.webkit.org/show_bug.cgi?id=230749" rel="noopener noreferrer"&gt;WebKit Bug 230749&lt;/a&gt; — WebGL performance regression on particles in Safari 15&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bugs.webkit.org/show_bug.cgi?id=239015" rel="noopener noreferrer"&gt;WebKit Bug 239015&lt;/a&gt; — Performance regression after uploading WebGL buffers (Safari 15.4)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.unity3d.com/Manual/shader-prewarm.html" rel="noopener noreferrer"&gt;Unity Manual: Prewarm shaders&lt;/a&gt; — official docs explicitly note that &lt;code&gt;Shader.WarmupAllShaders&lt;/code&gt; may not warm correctly on Metal/Vulkan because vertex layout differs from the actual draw call, which is exactly why we render through the main camera rather than calling &lt;code&gt;WarmupAllShaders&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://forum.unity.com/threads/shader-compile-frame-hitches-even-with-shader-warmupallshaders.465671/" rel="noopener noreferrer"&gt;Unity Forum: Shader Compile Frame Hitches even with &lt;code&gt;Shader.WarmupAllShaders&lt;/code&gt;&lt;/a&gt; — confirms the official API doesn’t fully solve the problem&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.blog.radiator.debacle.us/2023/01/unity-webgl-tips-advice-in-2023.html" rel="noopener noreferrer"&gt;Unity WebGL tips / advice in 2023 — Radiator Blog&lt;/a&gt; — general WebGL gotchas&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The specific case that bit us — &lt;strong&gt;a prefab &lt;code&gt;Instantiate&lt;/code&gt;d mid-game by an obscure event handler, whose first draw lands inside an unrelated critical animation one frame later&lt;/strong&gt; — isn’t covered in the existing material. The Safari Web Inspector &lt;code&gt;webglPrepareUniformLocations*&lt;/code&gt; markers as a diagnostic tool also seem underused; I didn’t find any reference to using them for Unity WebGL debugging.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Shader.WarmupAllShaders&lt;/code&gt; is not enough on Metal/Vulkan.&lt;/strong&gt; You have to &lt;em&gt;actually render&lt;/em&gt; the geometry through the same camera pipeline that gameplay will use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first draw call of any custom-shader prefab is expensive on Safari.&lt;/strong&gt; Plan for it. Render once during the splash. Engineering effort: one line per missed prefab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-registering warmup is the right pattern.&lt;/strong&gt; Each subsystem that spawns prefabs at runtime should warm its own prefab in &lt;code&gt;Awake&lt;/code&gt; instead of relying on a central registry. New systems get coverage automatically without anyone remembering to wire them up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnose with the Safari profiler, not by guessing.&lt;/strong&gt; We burned a full day on A/B isolation before realizing the answer was sitting in the Web Inspector timeline the whole time. The &lt;code&gt;webglPrepareUniformLocations*&lt;/code&gt; markers tell you exactly when first-use uniform discovery is happening — search for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pool spawned prefabs aren’t a substitute for warmup.&lt;/strong&gt; Pooling avoids &lt;code&gt;Instantiate&lt;/code&gt; allocation cost (microseconds). Warmup avoids first-draw shader compile cost (seconds). They solve different problems and you typically want both.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your Unity WebGL game has a “first time something happens” hitch on Safari that doesn’t repeat, you’re almost certainly looking at a Unity WebGL Safari hang of the same family. The Safari profile + &lt;code&gt;webglPrepareUniformLocationsBeforeFirstDraw&lt;/code&gt; markers are the fastest path to the answer. Hope this saves someone a long debugging session.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/unity-webgl-safari-hang-shader-warmup/" rel="noopener noreferrer"&gt;Unity WebGL Safari Hang: The First-Draw Shader Stall&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>shader</category>
      <category>webgl</category>
      <category>gamedev</category>
      <category>unity3d</category>
    </item>
    <item>
      <title>Re-skinning a 3D Character with AI Image Tools (Without Touching Blender)</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Sat, 02 May 2026 15:58:20 +0000</pubDate>
      <link>https://dev.to/furic/re-skinning-a-3d-character-with-ai-image-tools-without-touching-blender-4a14</link>
      <guid>https://dev.to/furic/re-skinning-a-3d-character-with-ai-image-tools-without-touching-blender-4a14</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76az1x1nik7cy1dzkhc2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76az1x1nik7cy1dzkhc2.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I bought a well-made chibi farmer model and wanted to reuse it as a zookeeper character — same mesh, same rig, same animations. Meshy.ai’s Retexture didn’t work for me, and naive cropping of the UV atlas pulled in neighbor pieces. The fix turned out to be embarrassingly simple: connected-component labeling to extract one UV island at a time, send each piece to ChatGPT with a tight prompt, then composite the AI result back with an intersection-of-silhouettes mask. I packaged the workflow as a Claude Code skill — &lt;a href="https://github.com/furic/texture-atlas-roundtrip" rel="noopener noreferrer"&gt;furic/texture-atlas-roundtrip&lt;/a&gt; — and ended up with a wildlife keeper that took a couple of evenings instead of a couple of weeks.&lt;/p&gt;

&lt;p&gt;&lt;span id="more-1017"&gt;&lt;/span&gt;&lt;/p&gt;





&lt;h2&gt;The problem&lt;/h2&gt;

&lt;p&gt;I’m a developer with no artist skills. For my causal game prototype, I needed a wildlife keeper character. I’d already paid for a beautifully animated chibi farmer (Suriyun’s Farmer SD pack), and the rig + animations were exactly what I wanted. The catch: the outfit was a plaid shirt + denim jeans + straw hat with a red band. Pure farmer.&lt;/p&gt;

&lt;p&gt;What I needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A khaki keeper shirt with chest pockets&lt;/li&gt;



&lt;li&gt;Khaki cargo pants/shorts&lt;/li&gt;



&lt;li&gt;A pith helmet instead of a straw hat&lt;/li&gt;



&lt;li&gt;Brown leather boots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I wanted to &lt;strong&gt;not&lt;/strong&gt; do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Touch the FBX, the rig, or any animation&lt;/li&gt;



&lt;li&gt;Open Blender to re-UV anything&lt;/li&gt;



&lt;li&gt;Hand-paint a 2048×2048 texture from scratch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The character has one big packed texture atlas — &lt;code&gt;Famer.png&lt;/code&gt; — with the shirt front, shirt back, sleeves, pants, belt, hat, leather accessories, and a few small bits all unwrapped onto a single image. That single PNG is what I needed to modify.&lt;/p&gt;





&lt;h2&gt;What didn’t work&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Meshy.ai’s retexture function.&lt;/strong&gt; I uploaded the FBX and tried both “text input” and “image input” prompts. The results were unrecognizable — colors smeared in the wrong places, details bleeding across UV seams. It felt like the model wasn’t understanding the UV mapping of the imported asset. Maybe there’s a config I missed, but I didn’t dig in: a one-click solution will probably exist in 12 months, but right now it doesn’t.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asking ChatGPT to redraw the whole atlas.&lt;/strong&gt; DALL-E doesn’t preserve UV island shapes pixel-precisely. Even with a strict “keep dimensions, keep silhouette” prompt, you get back a texture that &lt;em&gt;looks&lt;/em&gt; roughly right but seams shift, the hat brim rotates 5°, the collar moves. UV-bound textures are unforgiving — even a few-pixel shift causes visible artifacts on the 3D mesh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cropping a single piece out as a rectangle.&lt;/strong&gt; The atlas packs UV islands tightly to save memory, so the shirt-front bbox always includes slivers of the belt above and the suspender stripes that are technically in the same connected pixel region. Sending that to AI and pasting back contaminated the neighbor pieces.&lt;/p&gt;

&lt;p&gt;So the path forward needed to be: edit one piece at a time, keep its silhouette pixel-perfect, and protect everything else from collateral damage.&lt;/p&gt;





&lt;h2&gt;The solution: split → edit → composite&lt;/h2&gt;

&lt;p&gt;The technique has three parts, and only the splitting bit is non-obvious:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Split&lt;/strong&gt; the atlas into per-UV-island PNGs. Each piece is a 1024×1024 canvas with one island centered and everything else (including neighbor islands that share its bbox) masked to pure black.&lt;/li&gt;



&lt;li&gt;
&lt;strong&gt;Edit&lt;/strong&gt; each piece independently in any AI image tool. Because the background is solid black, the AI has a clean canvas and won’t draw beyond the island silhouette.&lt;/li&gt;



&lt;li&gt;
&lt;strong&gt;Composite&lt;/strong&gt; the AI’s result back into the atlas at the exact original pixel coordinates, using a mask that protects neighbor islands from accidental overwrites.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;Why naive cropping fails — and what to do instead&lt;/h3&gt;

&lt;p&gt;The atlas has roughly nine clearly visible UV islands, but they sit close together in the 2D image:&lt;/p&gt;

&lt;pre&gt;+-------------------------------+
|                               |
|   [BELT — wide horizontal]    |
|                               |
|  [SHIRT FRONT]   [SHIRT BACK] |
|                               |
|                  [SLEEVES][..]|
|                               |
|  [PANTS]        [STRAW HAT]   |
|                               |
+-------------------------------+
&lt;/pre&gt;

&lt;p&gt;If I rectangle-crop “shirt front”, I’m guaranteed to also catch a slice of the belt above it or the leather puffs on its right edge. Send that to AI, and the AI either repaints those slivers (corrupting the belt) or, more often, fills them with black — and “fills with black” is exactly what destroys the belt when I paste back.&lt;/p&gt;

&lt;p&gt;The fix is to extract the &lt;strong&gt;connected component&lt;/strong&gt; rather than a rectangle. &lt;code&gt;scipy.ndimage.label&lt;/code&gt; walks the image, assigns each contiguous non-black region its own integer ID, and gives you a per-pixel labels array. Then I crop the bbox AND zero every pixel whose label isn’t the one I want:&lt;/p&gt;

&lt;pre&gt;import numpy as np
from PIL import Image
from scipy import ndimage

img = np.array(Image.open("atlas.png").convert("RGBA"))
non_black = img[..., :3].sum(-1) &amp;gt; 30   # tolerate near-black gradients

labels, n = ndimage.label(non_black)
sizes = ndimage.sum(non_black, labels, range(1, n + 1))
slices = ndimage.find_objects(labels)

# Pick the island we want (e.g. by bbox heuristic or label_id)
target_label = 5
y0, y1 = slices[target_label - 1][0].start, slices[target_label - 1][0].stop
x0, x1 = slices[target_label - 1][1].start, slices[target_label - 1][1].stop

# Extract just this island — neighbors masked to black
piece = img[y0:y1, x0:x1].copy()
mask = labels[y0:y1, x0:x1] == target_label
piece[~mask] = [0, 0, 0, 255]
&lt;/pre&gt;

&lt;p&gt;Now I have a piece I can drop onto a 1024×1024 black canvas, save out as &lt;code&gt;shirt_front.png&lt;/code&gt;, and hand to any AI tool with a clean conscience.&lt;/p&gt;

&lt;p&gt;I also save a &lt;code&gt;manifest.json&lt;/code&gt; recording each piece’s original &lt;code&gt;src_bbox&lt;/code&gt;, &lt;code&gt;canvas_offset&lt;/code&gt;, &lt;code&gt;label_id&lt;/code&gt;, and (we’ll come back to this) &lt;code&gt;flip_y&lt;/code&gt;. The manifest is what makes the round trip work — without it, pasting back at the exact pixel position is guesswork.&lt;/p&gt;

&lt;h3&gt;The intersection-of-silhouettes paste-back&lt;/h3&gt;

&lt;p&gt;When the AI returns its result, the obvious composite is “paste the whole AI image into the original bbox.” But there are two failure modes hiding in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failure 1&lt;/strong&gt;: AI’s pure-black background covers neighbor islands that happen to fall inside the bbox. Belt fragment that lived in the corner of the shirt-front bbox? Now black.&lt;/li&gt;



&lt;li&gt;
&lt;strong&gt;Failure 2&lt;/strong&gt;: AI drew slightly outside the original silhouette (e.g. extended the shirt by 5px on the left). Those extra pixels land on parts of the texture that map to invisible mesh — but worse, they can land on a different UV island in the same bbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is the intersection of two silhouettes:&lt;/p&gt;

&lt;pre&gt;# AI silhouette: where the AI actually drew content
ai_silhouette = ai_result.sum(-1) &amp;gt; 30

# Original silhouette: where the target UV island lives in the atlas
src_labels, _ = ndimage.label(original_atlas.sum(-1) &amp;gt; 30)
orig_silhouette = src_labels[y0:y1, x0:x1] == target_label

# Paste only where both agree
mask = ai_silhouette &amp;amp; orig_silhouette
target[y0:y1, x0:x1][mask] = ai_resized[mask]
&lt;/pre&gt;

&lt;p&gt;Original-silhouette-only would let AI black overwrite neighbors. AI-silhouette-only would let AI bleed onto invisible mesh. The intersection covers both directions in one line.&lt;/p&gt;

&lt;p&gt;I add a tiny erosion + Gaussian blur on the mask to avoid hard edges where AI’s stroke meets the existing texture. That single trick is what makes the round trip clean enough to ship.&lt;/p&gt;





&lt;h2&gt;The Y-flip gotcha&lt;/h2&gt;

&lt;p&gt;I sent &lt;code&gt;shirt_front.png&lt;/code&gt; to ChatGPT with a careful prompt — “khaki shirt, two chest pockets, keep dimensions and silhouette” — and got back an excellent result. Pasted it back, opened Unity, and the pockets were on the keeper’s &lt;em&gt;waist&lt;/em&gt;. Upside down.&lt;/p&gt;

&lt;p&gt;Took me a minute to figure out why: the chibi character’s UV unwrap stores the shirt vertically flipped relative to mesh space. The V-notch at the bottom of the texture? That’s the &lt;strong&gt;collar&lt;/strong&gt; in 3D. When the AI looked at the piece, it saw a normal shirt right-way-up (because the collar happens to point down in the texture) and drew pockets in the upper half. Those pockets, mapped back to mesh, landed on the lower torso.&lt;/p&gt;

&lt;p&gt;The fix has two flavors:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flavor A — don’t pre-flip; flip the AI result during composite.&lt;/strong&gt;&lt;br&gt;Pockets will still be in the wrong half of the canvas, but the second flip puts them on the chest. Works for symmetric content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flavor B — pre-flip the input so AI works in natural orientation.&lt;/strong&gt;&lt;br&gt;Send a Y-flipped piece to AI, AI draws on a “right-side-up” shirt, then composite flips the result back. Two flips total. Better for asymmetric content (name tags, logos).&lt;/p&gt;

&lt;p&gt;I went with Flavor B and added a &lt;code&gt;flip_y: true&lt;/code&gt; field to the manifest entry for each Y-flipped piece. The composite step honors it automatically:&lt;/p&gt;

&lt;pre&gt;if effective_flip_y:
    edited_pil = edited_pil.transpose(Image.FLIP_TOP_BOTTOM)
&lt;/pre&gt;

&lt;p&gt;Discovery flow: composite once, see that pockets are upside-down, set &lt;code&gt;flip_y: true&lt;/code&gt; in the manifest, re-composite. No re-prompting needed.&lt;/p&gt;





&lt;h2&gt;The workflow in practice&lt;/h2&gt;

&lt;p&gt;Here’s the actual loop I ran for the keeper conversion:&lt;/p&gt;

&lt;pre&gt;SKILL=~/.claude/skills/texture-atlas-roundtrip

# 1. Split the farmer atlas into per-UV-island PNGs
python3 $SKILL/split.py Famer.png ./pieces/

# 2. Rename pieces in manifest.json (island_2 → shirt_front, etc.)
#    Mark Y-flipped pieces: "flip_y": true

# 3. (For Y-flipped pieces) pre-flip so AI sees natural orientation
python3 $SKILL/flip_piece.py shirt_front.png  # → shirt_front_flipped.png

# 4. Send each piece to ChatGPT with a prompt like:
#    "Redraw this UV texture as a khaki keeper shirt with two chest pockets,
#     keep 1024×1024 dimensions, pure black background, exact silhouette..."
#    Save the result back to the same filename.

# 5. Composite each edited piece into the keeper atlas
python3 $SKILL/composite.py M07_Keeper.png shirt_front shirt_front_edited.png ./pieces/manifest.json
&lt;/pre&gt;

&lt;p&gt;For the hat I used a slightly different sub-flow: extract the hat region with the connected-component mask, send to ChatGPT with a pith-helmet reference photo and a “top-down circular UV” prompt, composite back. Since the existing straw hat already had the right round silhouette for a top-down dome+brim view, AI just had to repaint the texture inside that silhouette — no UV alignment risk.&lt;/p&gt;

&lt;p&gt;Pants had one extra wrinkle: the “pants” UV island is actually pants AND boots in one piece, with a visible curve dividing them. After the first AI pass turned the whole island into khaki, the boots came out as khaki socks. Second pass with a “modify only below the curve” prompt added brown leather boots without disturbing the pants area.&lt;/p&gt;





&lt;h2&gt;Results&lt;/h2&gt;

&lt;p&gt;Before:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Freskin-3d-character-result-before.png%3Fresize%3D300%252C300%26ssl%3D1" class="article-body-image-wrapper"&gt;&lt;img width="300" height="300" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Freskin-3d-character-result-before.png%3Fresize%3D300%252C300%26ssl%3D1" alt=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Freskin-3d-character-result-after.png%3Fresize%3D300%252C300%26ssl%3D1" class="article-body-image-wrapper"&gt;&lt;img width="300" height="300" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Freskin-3d-character-result-after.png%3Fresize%3D300%252C300%26ssl%3D1" alt=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model, rig, animations, and FBX are completely untouched. The keeper texture is a single recolored + AI-edited variant of &lt;code&gt;Famer.png&lt;/code&gt;, drop-in compatible with the original material.&lt;/p&gt;

&lt;p&gt;For comparison, here’s Meshy.ai’s attempt — which is what pushed me to build this in the first place:&lt;/p&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Fmesh-ai-failed-retexture.png%3Fresize%3D1024%252C816%26ssl%3D1" class="article-body-image-wrapper"&gt;&lt;img width="1024" height="816" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fwww.richardfu.net%2Fwp-content%2Fuploads%2Fmesh-ai-failed-retexture.png%3Fresize%3D1024%252C816%26ssl%3D1" alt=""&gt;&lt;/a&gt;&lt;/p&gt;





&lt;h2&gt;Get the skill&lt;/h2&gt;

&lt;p&gt;I packaged the workflow as a Claude Code skill: &lt;strong&gt;&lt;a href="https://github.com/furic/texture-atlas-roundtrip" rel="noopener noreferrer"&gt;furic/texture-atlas-roundtrip&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;pre&gt;git clone https://github.com/furic/texture-atlas-roundtrip ~/.claude/skills/texture-atlas-roundtrip
pip install numpy Pillow scipy
&lt;/pre&gt;

&lt;p&gt;The repo includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SKILL.md&lt;/code&gt; — when to use, common mistakes, paste-back masking logic&lt;/li&gt;



&lt;li&gt;
&lt;code&gt;split.py&lt;/code&gt; — atlas → per-island PNGs + manifest&lt;/li&gt;



&lt;li&gt;
&lt;code&gt;composite.py&lt;/code&gt; — paste-back with intersection mask + Y-flip support&lt;/li&gt;



&lt;li&gt;
&lt;code&gt;flip_piece.py&lt;/code&gt; — vertical flip helper for Y-flipped UVs&lt;/li&gt;



&lt;li&gt;
&lt;code&gt;examples/&lt;/code&gt; — optional &lt;code&gt;/split-texture&lt;/code&gt; and &lt;code&gt;/merge-texture&lt;/code&gt; slash command shortcuts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roughly 200 lines of Python total. The whole thing exists because connected-component labeling solves an entire class of UV editing problems if you let it.&lt;/p&gt;





&lt;h2&gt;What I’d want next&lt;/h2&gt;

&lt;p&gt;A model-aware AI image tool that understands the UV → mesh mapping would make all of this obsolete. Meshy.ai’s retexture is the closest thing today and it didn’t work for my chibi character; presumably it’ll be solved in the next round. Until then, splitting an atlas and round-tripping pieces through ChatGPT is the most reliable workflow I’ve found, and it’s good enough that I’m planning to use it for the next four characters in this series.&lt;/p&gt;

&lt;p&gt;If you’ve been blocked on reskinning a 3D asset because you’re not an artist, give the skill a try and tell me where it falls down — issues and PRs welcome at the &lt;a href="https://github.com/furic/texture-atlas-roundtrip" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/re-skinning-a-3d-character-with-ai-image-tools-without-touching-blender/" rel="noopener noreferrer"&gt;Re-skinning a 3D Character with AI Image Tools (Without Touching Blender)&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>skill</category>
      <category>python</category>
      <category>modelling</category>
    </item>
    <item>
      <title>The FTX Collapse Had Warnings. An LLM Could Have Caught Them.</title>
      <dc:creator>Richard Fu</dc:creator>
      <pubDate>Thu, 05 Mar 2026 12:58:41 +0000</pubDate>
      <link>https://dev.to/furic/the-ftx-collapse-had-warnings-an-llm-could-have-caught-them-hop</link>
      <guid>https://dev.to/furic/the-ftx-collapse-had-warnings-an-llm-could-have-caught-them-hop</guid>
      <description>&lt;p&gt;&lt;strong&gt;Turning RSS feeds, Google Gemini, and a GitHub cron job into an early warning system for crypto exchange risk.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In November 2022, a cascade of news headlines told the story of FTX’s collapse in slow motion. Alameda’s balance sheet leaked on November 2nd. Binance announced it was dumping FTT on the 6th. By the 8th, withdrawal delays were making headlines. On the 11th, FTX filed for bankruptcy.&lt;/p&gt;

&lt;p&gt;Nine days. The signals were public, scattered across CoinTelegraph, Decrypt, The Block, and Google News. But most people — myself included — weren’t synthesizing that information fast enough. The problem wasn’t access. It was attention.&lt;/p&gt;

&lt;p&gt;That observation sat with me for a while. Not as a regret, but as an engineering question: &lt;strong&gt;what would it take to automate the “paying attention” part?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer turned out to be surprisingly small. About 450 lines of TypeScript, three npm dependencies, and an LLM that’s good at reading headlines.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bet: LLMs as Risk Filters, Not Risk Analyzers
&lt;/h2&gt;

&lt;p&gt;There’s a temptation to over-scope AI in financial applications — building trading bots, predicting prices, replacing analysts. Most of those projects fail because they ask the AI to be &lt;em&gt;right&lt;/em&gt; about uncertain things.&lt;/p&gt;

&lt;p&gt;Crypto Sentinel takes a different bet: use the LLM as a &lt;strong&gt;filter&lt;/strong&gt; , not an oracle. It doesn’t predict what will happen. It reads a batch of headlines and answers one narrow question: &lt;em&gt;does this collection of news suggest elevated risk for the exchanges I hold funds on?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That’s a classification task, not a prediction task. And classification is something current LLMs do reliably.&lt;/p&gt;

&lt;p&gt;The system defines five risk tiers with explicit criteria:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Trigger Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Critical&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Insolvency, confirmed hack, withdrawal freeze, regulatory shutdown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;High&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Major security breach, regulatory enforcement, suspected bank run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Medium&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Regulatory investigation, partnership failure, leadership departure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Low&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Minor negative press, market downturn, routine operational changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;None&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Neutral or positive coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only &lt;code&gt;medium&lt;/code&gt; and above triggers an alert. This single design choice — an aggressive threshold — is what prevents the system from becoming noise. Every notification that reaches my phone is worth reading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture: Six Stages, No Server
&lt;/h2&gt;

&lt;p&gt;The entire pipeline runs as a scheduled GitHub Actions job, four times a day. There’s no always-on server, no database, no container orchestration. Here’s the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
RSS Feeds ──&amp;gt; Keyword Filter ──&amp;gt; Dedup Cache ──&amp;gt; Gemini Analysis ──&amp;gt; Alerts
 (5 sources (configurable (MD5 of URL, (structured (email via
 + Google watchlist) last 500) JSON output) Resend +
   News) Telegram)

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

&lt;/div&gt;



&lt;p&gt;Each stage is a single TypeScript module. The orchestrator in &lt;code&gt;index.ts&lt;/code&gt; is 59 lines — a &lt;code&gt;for&lt;/code&gt; loop with error handling. Let’s walk through the interesting parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Aggregation: RSS + Google News with a Redirect Trap
&lt;/h3&gt;

&lt;p&gt;Four crypto-focused RSS feeds provide the baseline coverage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RSS_FEEDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CoinTelegraph&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cointelegraph.com/rss&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Decrypt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://decrypt.co/feed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The Block&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.theblock.co/rss.xml&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CryptoSlate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cryptoslate.com/feed/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Google News adds broader coverage, dynamically querying for each watched keyword (“bybit crypto”, “youhodler crypto”, etc.). But there’s a trap here that took some debugging.&lt;/p&gt;

&lt;p&gt;Google News RSS doesn’t serve direct article URLs. Every link is a redirect wrapper: &lt;code&gt;news.google.com/rss/articles/CBMi...&lt;/code&gt;. In a browser, this transparently redirects to the real article. But email services like Resend wrap outbound links in their own click-tracking redirect. So clicking a link in the email creates a &lt;strong&gt;double redirect&lt;/strong&gt; : Resend → Google News → actual article. Google interprets that chain as bot traffic and blocks it with a CAPTCHA page.&lt;/p&gt;

&lt;p&gt;The fix resolves Google News URLs at ingestion time, before they ever reach the email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolveGoogleNewsUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HEAD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;follow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;manual&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;location&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fall through */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The HEAD-first approach minimizes bandwidth. If the server doesn’t support HEAD (some don’t), it falls back to a manual redirect extraction. Worst case, the original Google News URL passes through unchanged.&lt;/p&gt;

&lt;p&gt;All resolutions happen in parallel via &lt;code&gt;Promise.all&lt;/code&gt;, so this doesn’t meaningfully slow down the pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deduplication: MD5 Hashing with a Rolling Window
&lt;/h3&gt;

&lt;p&gt;Each article is identified by the MD5 hash of its resolved URL. A JSON file caches the last 500 seen hashes — roughly a week of coverage at current volume. The cache is persisted between GitHub Actions runs using &lt;code&gt;actions/cache@v4&lt;/code&gt; with a rolling key strategy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cache.json&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sentinel-cache-${{ github.run_id }}&lt;/span&gt;
    &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sentinel-cache-&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Every run creates a new cache key (by run ID), but restores from the most recent one. This means the cache auto-updates without manual pruning of stale keys.&lt;/p&gt;

&lt;p&gt;Why not a database? Because a 16 KB JSON file with 500 hex strings doesn’t need one. The entire state model fits in a single &lt;code&gt;fs.readFileSync&lt;/code&gt; call.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Gemini Prompt: Structured Output via Role-Playing
&lt;/h3&gt;

&lt;p&gt;The prompt engineering is deliberate. Rather than asking Gemini to “analyze these headlines,” it’s given an explicit role and output contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`You are a crypto-exchange risk analyst. Given these headlines
about crypto exchanges, assess the overall risk level.

Risk levels:
- CRITICAL: insolvency, confirmed hack, withdrawal freeze, regulatory shutdown
- HIGH: major security breach, regulatory enforcement, suspected bank run
- MEDIUM: regulatory warning, partnership failure, major leadership departure
- LOW: minor negative press, market downturn, routine changes
- NONE: neutral news, positive coverage

Headlines:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;headlines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;

Return ONLY valid JSON:
{ "risk_level": "...", "summary": "1-2 sentence summary", "alerts": ["concern 1", ...] }`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Three things matter here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enumerated risk levels with examples&lt;/strong&gt; — removes ambiguity about what “high” means&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbered headlines&lt;/strong&gt; — helps the model reference specific items in its summary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Return ONLY valid JSON”&lt;/strong&gt; — reduces the chance of markdown wrappers or preamble text&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When JSON parsing fails (it happens ~2% of the time with Flash models), the system falls back to &lt;code&gt;low&lt;/code&gt; risk with a manual review flag rather than crashing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alert Formatting: Color-Coded HTML for Fast Scanning
&lt;/h3&gt;

&lt;p&gt;The email alert is designed to be scannable in under 10 seconds. A color-coded header immediately communicates severity:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fagd54at9wvnab5w6pvtg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fagd54at9wvnab5w6pvtg.png" alt="Crypto Sentinel email alert showing MEDIUM risk with AI summary and source articles"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;riskColors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RiskLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#dc2626&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Red — immediate action&lt;/span&gt;
  &lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ea580c&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Orange — urgent attention&lt;/span&gt;
  &lt;span class="na"&gt;medium&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#d97706&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Amber — worth investigating&lt;/span&gt;
  &lt;span class="na"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#65a30d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Green — low concern&lt;/span&gt;
  &lt;span class="na"&gt;none&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#6b7280&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Gray — informational&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Below the header: AI summary, specific concerns as bullets, and a table of source articles with direct links. Telegram gets a condensed version — same information hierarchy, fewer articles (10 vs 20), plain text formatting.&lt;/p&gt;

&lt;p&gt;Telegram is entirely optional and implemented with raw &lt;code&gt;fetch()&lt;/code&gt; against the Bot API. No SDK, no npm dependency. If the env vars aren’t configured, it silently skips. If the API call fails, the error is logged but doesn’t block the email alert.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resilience as a Feature
&lt;/h2&gt;

&lt;p&gt;A monitoring system that crashes is worse than no monitoring system — it gives false confidence. Every stage in the pipeline is designed to degrade rather than fail:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One RSS feed times out&lt;/td&gt;
&lt;td&gt;Other feeds still process; warning logged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google News returns 403&lt;/td&gt;
&lt;td&gt;Skipped; dedicated feeds provide baseline coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini returns invalid JSON&lt;/td&gt;
&lt;td&gt;Falls back to &lt;code&gt;low&lt;/code&gt; risk + manual review flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resend API error&lt;/td&gt;
&lt;td&gt;Hard fail (email is the primary channel — this &lt;em&gt;should&lt;/em&gt; be loud)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram API error&lt;/td&gt;
&lt;td&gt;Logged, not propagated; email already sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache file missing/corrupt&lt;/td&gt;
&lt;td&gt;Starts fresh; may re-alert on seen articles (acceptable)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only intentional hard failures are missing API keys for Gemini and Resend. Everything else bends rather than breaks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack, and Why Each Piece
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Alternative Considered&lt;/th&gt;
&lt;th&gt;Why This One&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TypeScript (strict)&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;Type safety for AI response parsing; catches schema mismatches at compile time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;GPT-4o-mini, Claude Haiku&lt;/td&gt;
&lt;td&gt;Generous rate limits on the free tier (250 req/day); sub-second for classification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Email&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;SendGrid, AWS SES&lt;/td&gt;
&lt;td&gt;Simplest API surface; works without domain verification via shared sender&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Messaging&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Telegram Bot API&lt;/td&gt;
&lt;td&gt;Slack, Discord&lt;/td&gt;
&lt;td&gt;Native mobile push; no OAuth dance; direct &lt;code&gt;fetch()&lt;/code&gt; with zero dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RSS parsing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;rss-parser&lt;/td&gt;
&lt;td&gt;feedparser, custom&lt;/td&gt;
&lt;td&gt;Handles RSS 2.0 and Atom; tolerant of malformed feeds; 10s timeout built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scheduling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions cron&lt;/td&gt;
&lt;td&gt;AWS Lambda, Vercel cron&lt;/td&gt;
&lt;td&gt;Secrets management built in; cache persistence built in; already where the code lives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Persistence&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSON file&lt;/td&gt;
&lt;td&gt;SQLite, Redis&lt;/td&gt;
&lt;td&gt;16 KB of data doesn’t justify a database; human-readable for debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total production dependencies: &lt;code&gt;@google/generative-ai&lt;/code&gt;, &lt;code&gt;resend&lt;/code&gt;, &lt;code&gt;rss-parser&lt;/code&gt;. That’s it. Everything else is native Node.js 22 (including &lt;code&gt;fetch&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas Worth Knowing
&lt;/h2&gt;

&lt;p&gt;A few things that weren’t obvious upfront:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google News RSS is region-locked.&lt;/strong&gt; The &lt;code&gt;ceid&lt;/code&gt; and &lt;code&gt;gl&lt;/code&gt; parameters control which regional edition you get. &lt;code&gt;AU:en&lt;/code&gt; returns Australian English results. If you’re watching for news about a Southeast Asian exchange, you might want &lt;code&gt;SG:en&lt;/code&gt; or multiple regional queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resend’s click tracking creates double redirects.&lt;/strong&gt; Any URL in a Resend email gets wrapped in a &lt;code&gt;resend-clicks.com&lt;/code&gt; tracking redirect. If the original URL is &lt;em&gt;also&lt;/em&gt; a redirect (like Google News), the target server may block the chained request. Always resolve redirects before including URLs in emails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM JSON output isn’t guaranteed.&lt;/strong&gt; Even with explicit “return ONLY valid JSON” instructions, Gemini occasionally wraps the response in markdown code fences or adds a preamble. The &lt;code&gt;JSON.parse&lt;/code&gt; call needs a &lt;code&gt;try/catch&lt;/code&gt; with a sensible fallback — not just for robustness, but because the failure mode (crashing at 3 AM with no alert) is worse than the degraded mode (a slightly less precise risk assessment).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Actions cron is approximate.&lt;/strong&gt; The &lt;code&gt;schedule&lt;/code&gt; trigger doesn’t guarantee exact timing — GitHub queues jobs, and during high load, runs can be delayed by 15-30 minutes. For a monitoring system that runs 4x daily, this is fine. For anything requiring precise timing, it’s not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;actions/cache&lt;/code&gt; has a 10 GB limit per repo.&lt;/strong&gt; With a rolling key strategy, old cache entries accumulate. For a 16 KB file this is irrelevant, but worth knowing if you extend the pattern to larger datasets.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Broader Pattern
&lt;/h2&gt;

&lt;p&gt;Strip away the crypto-specific parts and what remains is a general-purpose architecture for &lt;strong&gt;AI-augmented monitoring of public information&lt;/strong&gt; :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Aggregate&lt;/strong&gt; from multiple public data sources (RSS, APIs, web scraping)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter&lt;/strong&gt; by relevance criteria (keywords, rules, heuristics)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate&lt;/strong&gt; against a rolling history to avoid re-processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify&lt;/strong&gt; using an LLM with structured output and explicit criteria&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route&lt;/strong&gt; alerts conditionally based on severity thresholds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deliver&lt;/strong&gt; through multiple channels with graceful degradation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This same pipeline could monitor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Competitor news&lt;/strong&gt; for a product team&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regulatory filings&lt;/strong&gt; in a specific industry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security advisories&lt;/strong&gt; for your dependency stack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brand mentions&lt;/strong&gt; across news outlets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supply chain disruptions&lt;/strong&gt; for logistics operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LLM is the key differentiator from traditional keyword alerts. It can distinguish between &lt;em&gt;“Exchange X partners with major bank”&lt;/em&gt; (positive) and &lt;em&gt;“Exchange X under investigation by major bank regulator”&lt;/em&gt; (concerning) — something a keyword filter fundamentally cannot do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The project is open source and designed to be forked. Three API keys (all free-tier, no credit card), a few GitHub secrets, and you have a running monitor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/furic/crypto-sentinel" rel="noopener noreferrer"&gt;github.com/furic/crypto-sentinel&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://furic.github.io/crypto-sentinel/" rel="noopener noreferrer"&gt;furic.github.io/crypto-sentinel&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
&lt;span class="c"&gt;# Local development&lt;/span&gt;
git clone https://github.com/furic/crypto-sentinel.git
&lt;span class="nb"&gt;cd &lt;/span&gt;crypto-sentinel &amp;amp;amp&lt;span class="p"&gt;;&lt;/span&gt;&amp;amp;amp&lt;span class="p"&gt;;&lt;/span&gt; npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env &lt;span class="c"&gt;# Add your API keys&lt;/span&gt;
npm run dev

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

&lt;/div&gt;



&lt;p&gt;The whole thing is ~450 lines of TypeScript. Read it in an afternoon, fork it, make it yours.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The next exchange collapse will have warning signs. The question is whether you’ll be reading headlines fast enough to notice them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://www.richardfu.net/the-ftx-collapse-had-warnings-an-llm-could-have-caught-them/" rel="noopener noreferrer"&gt;The FTX Collapse Had Warnings. An LLM Could Have Caught Them.&lt;/a&gt; appeared first on &lt;a href="https://www.richardfu.net" rel="noopener noreferrer"&gt;Richard Fu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>crypto</category>
      <category>finance</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
