<?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: Ted</title>
    <description>The latest articles on DEV Community by Ted (@henry_dan_81513dd35a2f540).</description>
    <link>https://dev.to/henry_dan_81513dd35a2f540</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%2F2256153%2Fd3b27e5a-9e82-4c4d-b481-9b835d4deea3.png</url>
      <title>DEV Community: Ted</title>
      <link>https://dev.to/henry_dan_81513dd35a2f540</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/henry_dan_81513dd35a2f540"/>
    <language>en</language>
    <item>
      <title>I Ignored Bing for Months. It Ranked My Pages Backwards from Google.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:42:54 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/i-ignored-bing-for-months-it-ranked-my-pages-backwards-from-google-2gbi</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/i-ignored-bing-for-months-it-ranked-my-pages-backwards-from-google-2gbi</guid>
      <description>&lt;p&gt;I'd spent months optimizing for one search engine. Not on purpose — Google is just where I looked. Search Console was wired into my morning reports, the numbers showed up in Telegram every day, and that was "search performance." Bing Webmaster Tools existed in a browser tab I never opened.&lt;/p&gt;

&lt;p&gt;When I finally opened it, the data didn't agree with Google. It actively contradicted it.&lt;/p&gt;

&lt;p&gt;On the site, two pages cover the same subject. One is a dated long-form article — the "everything about this topic, updated for 2026" piece. The other is an interactive map that renders the same underlying data geographically: pick a place, see its status. They target the same cluster of queries.&lt;/p&gt;

&lt;p&gt;On Google, the article wins decisively — roughly five times the impressions of the map, and a better average position. I'd internalized that as settled: Google had picked the article as the answer, end of story.&lt;/p&gt;

&lt;p&gt;On Bing, the map wins. The article is barely present. The map page alone accounts for &lt;strong&gt;56% of every impression the site gets on Bing.&lt;/strong&gt; The article that dominates Google sits near the bottom of the list.&lt;/p&gt;

&lt;p&gt;Same two pages. Opposite order. Two engines looking at identical content and disagreeing about which one deserves to rank.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Google&lt;/th&gt;
&lt;th&gt;Bing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Long-form article&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Wins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Loses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactive map&lt;/td&gt;
&lt;td&gt;Loses&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Wins&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why an engine "picks" a page
&lt;/h2&gt;

&lt;p&gt;This isn't randomness, and it isn't a quirk of one crawl. The two engines reward different things for the same informational query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google rewards the article.&lt;/strong&gt; Its helpful-content systems lean hard toward substantive, dated, written pages for informational intent. A long article with depth, a clear publish date, and a "2026" freshness signal reads to Google as &lt;em&gt;the authoritative answer&lt;/em&gt; to "tell me about this topic." The map page — mostly an interactive widget with thin surrounding text — reads as a tool, not an answer. Google buries it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bing rewards the tool.&lt;/strong&gt; Bing is more literal. When the query itself implies a lookup — anything phrased as a &lt;em&gt;map&lt;/em&gt; or a &lt;em&gt;by-location&lt;/em&gt; question — Bing matches the page whose entire purpose is that lookup. The map page's title, structure, and intent line up with the query word-for-word, so Bing puts it first. The article, to Bing, is just a wall of text adjacent to the thing the user actually asked for.&lt;/p&gt;

&lt;p&gt;Neither is wrong. They're optimizing for different definitions of "best result," and my two pages happened to be perfect representatives of each definition. So each engine crowned a different king.&lt;/p&gt;

&lt;p&gt;The inversion showed up at the sub-page level too, even more starkly: one regional reference page ranks on the &lt;strong&gt;first page of Bing&lt;/strong&gt; and sits at &lt;strong&gt;position 69 on Google&lt;/strong&gt; — effectively invisible. Same page. The gap there isn't content; it's authority. Those head terms on Google are owned by encyclopedias, government sites, and AI summaries, and a young page doesn't crack them. Bing's bar is lower, so the same page surfaces. Watching only Google, I'd have called that page a failure. It isn't — it's just winning on the engine I never checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zero-click trap
&lt;/h2&gt;

&lt;p&gt;Here's the part that nearly fooled me twice.&lt;/p&gt;

&lt;p&gt;The map page's top Bing queries — the high-volume, head-of-cluster ones — rank around positions 6 to 9 and convert &lt;strong&gt;almost no clicks.&lt;/strong&gt; My first read was "impressions without clicks, so this is worthless ranking."&lt;/p&gt;

&lt;p&gt;That might be the wrong conclusion. One likely explanation — and I want to be careful to call it a hypothesis, not a proven cause — is that some of those impressions are being &lt;strong&gt;answered inline by an AI assistant&lt;/strong&gt;: the model pulls the page into its grounding set, writes the answer, and the user never clicks because they already got what they came for. I've &lt;a href="https://tedagentic.com/posts/ai-citation-aeo-geo-perplexity" rel="noopener noreferrer"&gt;written before about why AI citation is becoming the metric that replaces the click&lt;/a&gt;, and a high-impression, zero-click, mid-position query is exactly the shape that pattern leaves behind.&lt;/p&gt;

&lt;p&gt;Here's the honest limit, though: &lt;strong&gt;I can't confirm it from the data I have.&lt;/strong&gt; The search engine's webmaster API reports impressions, clicks, and positions — it does &lt;em&gt;not&lt;/em&gt; expose whether an AI surface cited the page. So "these zero-click impressions are AI citations" is an inference from the shape of the numbers, not something I measured. It's plausible, it fits, and it's the explanation I'd bet on — but it's a bet, and treating it as confirmed would be the exact &lt;a href="https://tedagentic.com/posts/ai-confidence-gap-production-sites" rel="noopener noreferrer"&gt;confidence gap&lt;/a&gt; I keep warning about. What I &lt;em&gt;can&lt;/em&gt; say for certain is narrower and still useful: zero clicks at position 6–9 does not mean zero value, and reading it as failure would be a mistake.&lt;/p&gt;

&lt;p&gt;So "zero clicks" on those queries isn't dead weight. It's the citation surface. The clicks I &lt;em&gt;do&lt;/em&gt; get come from longer-tail queries lower in the cluster, ranking higher, where no AI summary intercepts them. Two completely different value mechanisms inside the same page, and you can only tell them apart if you stop treating clicks as the only outcome that counts. (If the GEO/AEO/citation vocabulary is new, I &lt;a href="https://tedagentic.com/posts/geo-generative-engine-optimization" rel="noopener noreferrer"&gt;untangled the three terms here&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What watching one engine hid
&lt;/h2&gt;

&lt;p&gt;I would not have seen any of this from Google alone. Not the inversion, not the page that's invisible on one engine and page-one on the other, not the citation pattern. Google's data is internally consistent and tells a clean story — &lt;em&gt;the article is the answer&lt;/em&gt; — and that story is true on Google and false everywhere else.&lt;/p&gt;

&lt;p&gt;That's the part that actually unsettled me, and it's bigger than Bing. For years my working definition was &lt;em&gt;Google = search&lt;/em&gt;. One dashboard, one set of numbers, one verdict on whether a page worked. But search has quietly become plural — Google, Bing, AI Overviews, Perplexity, ChatGPT, Gemini — and they don't agree, because they don't rank the same way or even reward the same kind of page. My mental model wasn't wrong so much as &lt;em&gt;incomplete&lt;/em&gt;: I was reading one instrument and calling it the weather. The inversion was just the first thing that didn't fit, because it was the first time I looked at a second instrument.&lt;/p&gt;

&lt;p&gt;The fix was boring and is the actual point: pipe the second engine into the same place as the first. Bing exposes a Webmaster API with the same kind of search-performance data Google's does — totals, top pages, top queries, positions — behind a single API key you generate in the tool's settings. I &lt;a href="https://tedagentic.com/posts/automating-seo-monitoring-with-ai-agents" rel="noopener noreferrer"&gt;wired Google Search Console into a daily Telegram briefing months ago&lt;/a&gt;; adding Bing was the same shape of work pointed at a different endpoint. Now both engines land in the same thread, weekly, side by side — a short "this week vs last week" and a six-month trend so I can watch the curve. (That curve, for the record, has compounded every month from a near-zero start — visibility I had no idea was accruing because I wasn't looking.)&lt;/p&gt;

&lt;p&gt;One gotcha worth saving someone an hour: the related &lt;strong&gt;IndexNow&lt;/strong&gt; endpoint — the protocol that lets you ping Bing and others the instant a URL changes — returned &lt;code&gt;403 Forbidden&lt;/code&gt; for every request until I added a &lt;code&gt;User-Agent&lt;/code&gt; header. &lt;code&gt;curl&lt;/code&gt; and the browser don't care; the API does. No error message explains it. If your IndexNow submissions are silently rejected, send a User-Agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;"Rank" isn't one number. The same page can be a winner and a loser at the same moment depending on who's asking, and the two biggest engines can disagree completely about content they're both looking at.&lt;/p&gt;

&lt;p&gt;The practical move once you see the split is &lt;em&gt;not&lt;/em&gt; to force the pages to fight — I'm not stripping the article of the terms it wins on Google to help the map, or vice versa. They've each won an engine. The right move is to let them, cross-link them so they reinforce instead of compete, and keep both engines on the dashboard so the next divergence isn't invisible for months.&lt;/p&gt;

&lt;p&gt;But the durable lesson isn't about Bing at all. It's that treating one engine as the whole of search means your picture is incomplete by definition — and you won't know what you're missing until you instrument the engines you've been ignoring. Mine had a story in it the whole time. I just wasn't looking.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://tedagentic.com/posts/bing-google-ranking-inversion" rel="noopener noreferrer"&gt;tedagentic.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>bing</category>
      <category>google</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Chrome Showed the Data. Firefox Showed Nothing. The API Was Being Blocked.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 17 Jun 2026 15:26:31 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/chrome-showed-the-data-firefox-showed-nothing-the-api-was-being-blocked-4mpn</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/chrome-showed-the-data-firefox-showed-nothing-the-api-was-being-blocked-4mpn</guid>
      <description>&lt;p&gt;I was looking at the same page in two browsers side by side. In Chrome: a full list of results, images, working buttons. In Firefox: a different, smaller set of placeholder cards with no images and no links — and a banner that read "0 results found."&lt;/p&gt;

&lt;p&gt;Same URL. Same deployed build. No "works on my machine" excuse, because it &lt;em&gt;wasn't&lt;/em&gt; working on the other machine — it was the same machine, two windows.&lt;/p&gt;

&lt;p&gt;That contradiction is the whole story, and the cause was a class of bug that is almost impossible to catch in development: &lt;strong&gt;the browser was blocking my own API call, and I'd been developing in the one browser that didn't.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the page is built
&lt;/h2&gt;

&lt;p&gt;The page renders a list of results that come from a database. The pattern is the common one for a single-page app: the page ships, then a bit of JavaScript runs and fetches the data from a hosted Postgres/API service (Supabase, in this case) at its own domain — something like &lt;code&gt;xxxx.supabase.co&lt;/code&gt;. The results come back, React renders the cards.&lt;/p&gt;

&lt;p&gt;If that fetch returns nothing, the component falls back to a hardcoded placeholder list so the page isn't blank. That fallback is what Firefox was showing. So the real question was narrow: &lt;strong&gt;why did the fetch return nothing in Firefox but everything in Chrome?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnosis
&lt;/h2&gt;

&lt;p&gt;The server was fine. I could hit the API directly from the command line and get the full result set, HTTP 200. The database, the credentials, the query — all correct. Chrome proved it too: it ran the exact same JavaScript against the exact same endpoint and got the data.&lt;/p&gt;

&lt;p&gt;The difference was the browser, and specifically what the browser &lt;em&gt;allowed&lt;/em&gt;. Firefox ships with Enhanced Tracking Protection, and a lot of people add uBlock Origin or Privacy Badger on top. Those tools block requests to domains they classify as third-party trackers. The relevant move is in the browser's network panel: the request to &lt;code&gt;xxxx.supabase.co&lt;/code&gt; wasn't failing with an error from the server — it was being &lt;strong&gt;blocked before it ever left&lt;/strong&gt;, by the privacy layer.&lt;/p&gt;

&lt;p&gt;From my code's point of view, the fetch just throws. &lt;code&gt;data&lt;/code&gt; comes back empty. The component does what I told it to do with no data: show the fallback. No crash, no red console error a casual glance would notice. The page "works." It just works with nothing in it.&lt;/p&gt;

&lt;p&gt;The whole bug fits in two columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Chrome                      Firefox + tracking protection

   Page                        Page
    ↓                           ↓
   Fetch supabase.co           Fetch supabase.co
    ↓                           ↓
   Data arrives                Blocked
    ↓                           ↓
   Render results              Fallback cards
                                ↓
                               0 results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code reaching the same fork, and the third step decides everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is so easy to ship
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. I build in Chrome. Most developers build in Chrome. Chrome, with no aggressive privacy extensions, happily makes the cross-origin call, so the page is perfect every single time I look at it. The failure only appears in an environment I never use during development.&lt;/p&gt;

&lt;p&gt;And it gets worse, because the &lt;em&gt;measurement&lt;/em&gt; is blocked too. My click tracking — the thing that tells me whether people are using the page — went through the same API domain. So the users who couldn't see the content also couldn't be counted &lt;em&gt;not&lt;/em&gt; using it. The bug hides in the browser I don't test, and it erases its own evidence in the analytics. It's invisible twice.&lt;/p&gt;

&lt;p&gt;The only reason I found it is that someone opened the page in Firefox and said "this looks broken."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: stop the fallback being a dead end
&lt;/h2&gt;

&lt;p&gt;The first thing I did was cheap and defensive. The placeholder fallback had no links — a blocked user landed on dead cards and had nowhere to go. So I made every fallback card and every "quick pick" link to the real directory page. Now, whatever blocks the fetch, the visitor still has a working path forward.&lt;/p&gt;

&lt;p&gt;This is worth doing regardless, but be honest about what it is: a &lt;strong&gt;safety net&lt;/strong&gt;, not a fix. The user is still being denied the real content. They're just no longer stranded. To actually fix it, the call has to stop being blockable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: make the call first-party
&lt;/h2&gt;

&lt;p&gt;It's worth being precise about what I'm fixing here, because it's easy to mislabel. This isn't a Supabase problem — Supabase did everything right. It's a &lt;strong&gt;browser trust-boundary problem&lt;/strong&gt;. The browser draws a line between "the site I'm on" and "some other domain," and privacy filters police that line. My data was on the wrong side of it.&lt;/p&gt;

&lt;p&gt;So the fix isn't to change the database. It's to move the request to the trusted side of the line — to change what the browser &lt;em&gt;sees&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Before                       After

   mysite.com                   mysite.com
    ↓                            ↓
   xxxx.supabase.co             mysite.com/sb-api
   (third-party — policed)      (first-party — trusted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same data, same backend. The only thing that changed is whose domain the browser thinks it's talking to. You do that with a reverse proxy: route the API through your own domain so the browser only ever sees a same-origin request to the site it's already on. On Vercel that's a rewrite in &lt;code&gt;vercel.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/sb-api/:path*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://xxxx.supabase.co/:path*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point the client at the first-party path instead of the vendor's domain:&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;SUPABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PROD&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&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="nb"&gt;window&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="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sb-api`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RAW_SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// dev + build-time stay on the direct URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the browser makes a request to &lt;code&gt;mysite.com/sb-api/...&lt;/code&gt;, which Vercel quietly forwards to the real API. To Firefox's tracking protection it's a call to the site you're already visiting — first-party, not a tracker, not blocked. As a bonus, the browser no longer has to negotiate cross-origin access at all.&lt;/p&gt;

&lt;p&gt;I verified it the same way I found the bug — by hitting the new first-party URL and watching the real data come back through it — then opened the page in Firefox and got the full list, images and all, identical to Chrome.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas, because there are always gotchas
&lt;/h2&gt;

&lt;p&gt;A reverse proxy in front of your whole data layer is a real change. Three things to check before you ship it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Websockets don't ride through a simple rewrite.&lt;/strong&gt; If you use the service's realtime/subscription features, a path rewrite won't proxy the socket cleanly. I got to skip this because the app uses none — but check first, or you'll trade a Firefox bug for a realtime outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin your auth storage key.&lt;/strong&gt; Client libraries often derive the key they store the session under from the API URL. Change the URL and the key changes, and everyone silently gets logged out. Set the storage key explicitly to the original value so swapping the URL is invisible to existing sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep server-side and build-time calls on the direct URL.&lt;/strong&gt; Anything running outside the browser — a build step, prerendering, server code — has no ad blocker and no same-origin to honor. Sending it through the proxy is pointless and can be circular. Gate the rewrite to the browser only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The crawler is just another blocked client
&lt;/h2&gt;

&lt;p&gt;There's a tidy way to think about all of this. A browser with tracking protection is a client that won't run part of your page. A search crawler is &lt;em&gt;also&lt;/em&gt; a client that won't run part of your page — it may not execute your fetch at all. Both end up looking at the empty shell.&lt;/p&gt;

&lt;p&gt;So the durable answer isn't only the proxy; it's not making the page's existence depend on a fetch that some clients will never complete. I also prerender the real results into the page's static HTML at build time, so the meaningful content is in the document before any JavaScript — for crawlers, for blocked browsers, for anyone whose script never runs. The proxy fixes the live experience; prerendering makes sure there's something real there even when nothing runs at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A page that's perfect in Chrome can be empty in Firefox.&lt;/strong&gt; If your data comes from a client-side call to a third-party domain, privacy filters will block it for a real slice of users, and you'll never see it in the browser you develop in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent failure is the dangerous kind.&lt;/strong&gt; A blocked fetch doesn't crash — it just renders your empty state. Make the empty state recoverable, but don't mistake that for a fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The blocked users are uncounted users.&lt;/strong&gt; If your analytics and your content share an API domain, the people who can't see the page also don't show up as not seeing it. Test in Firefox with tracking protection on, and with an ad blocker — that's your blind spot, on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-party proxy beats third-party fetch.&lt;/strong&gt; Route vendor APIs through your own domain so the browser sees same-origin. Mind websockets, pin the auth key, keep build/server calls direct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat crawlers and blocked browsers as the same problem.&lt;/strong&gt; Anything that won't run your JavaScript needs the real content in the HTML already.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Underneath the specifics, this was the same shape as most of the worst bugs I find:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Human validation passed
        ↓
System validation failed
        ↓
Find the hidden assumption
        ↓
Fix the assumption, not the symptom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hidden assumption here was four words long — &lt;em&gt;"if it works in Chrome"&lt;/em&gt; — and the real version was &lt;em&gt;"it works in the browser I happened to develop in."&lt;/em&gt; The fix wasn't really a proxy. The proxy was just how I retired the assumption.&lt;/p&gt;

&lt;p&gt;The page had been broken for months for an entire category of visitors. It looked flawless the whole time — in the one browser I happened to use.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>frontend</category>
    </item>
    <item>
      <title>My Redirects Worked in the Browser. Googlebot Saw Soft 404s.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sun, 14 Jun 2026 11:06:53 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/my-redirects-worked-in-the-browser-googlebot-saw-soft-404s-1pl4</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/my-redirects-worked-in-the-browser-googlebot-saw-soft-404s-1pl4</guid>
      <description>&lt;p&gt;Google Search Console flagged six URLs on a client site as &lt;strong&gt;Soft 404&lt;/strong&gt;. Every one of them returned a clean &lt;code&gt;200 OK&lt;/code&gt; when I curled it. So how does a page that loads fine get reported as "not found"?&lt;/p&gt;

&lt;p&gt;That contradiction is the whole story, and the answer turned out to be a category of bug I'd been shipping without realizing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Soft 404" actually means
&lt;/h2&gt;

&lt;p&gt;A hard 404 is honest: the server returns a 404 status, Google drops the URL, everyone moves on. A &lt;strong&gt;soft&lt;/strong&gt; 404 is when the server returns &lt;code&gt;200 OK&lt;/code&gt; but the &lt;em&gt;content&lt;/em&gt; looks like an error or an empty page to Google. The status line says "here's your page," the body says "there's nothing here." Google trusts the body.&lt;/p&gt;

&lt;p&gt;On a single-page app, there's a very common way to produce exactly that.&lt;/p&gt;

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

&lt;p&gt;The site is a React/Vite SPA, deployed on Vercel, with a prerender layer that injects real HTML for SEO so the crawler doesn't have to run JavaScript. Routes that are prerendered get proper content. Routes that &lt;em&gt;aren't&lt;/em&gt; fall through to Vercel's catch-all, which serves the app shell — effectively the homepage HTML — at whatever URL was requested.&lt;/p&gt;

&lt;p&gt;Hold onto that last sentence. It's the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fingerprint
&lt;/h2&gt;

&lt;p&gt;All six soft-404s shared a pattern: they were &lt;strong&gt;old, renamed blog slugs&lt;/strong&gt;. A few were year-suffixed posts I'd renamed (think &lt;code&gt;/blog/some-guide-2025&lt;/code&gt; → &lt;code&gt;/blog/some-guide-2026&lt;/code&gt;). Two were older duplicates of a post that now lives at a cleaner URL. One was the same path on the &lt;code&gt;www&lt;/code&gt; host instead of the apex.&lt;/p&gt;

&lt;p&gt;Old URLs that should redirect somewhere. And in the codebase, three of them &lt;em&gt;did&lt;/em&gt; have redirects. That's what made this confusing — I had redirects, and Google was still calling the pages broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: the redirects were client-side
&lt;/h2&gt;

&lt;p&gt;The three "redirected" routes used the SPA router's redirect component — the React-Router &lt;code&gt;&amp;lt;Navigate&amp;gt;&lt;/code&gt; element. In a browser, it works perfectly: the app boots, the router matches the old path, and the user is bounced to the new URL before they notice.&lt;/p&gt;

&lt;p&gt;But look at the order of operations from &lt;strong&gt;Googlebot's&lt;/strong&gt; point of view:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request &lt;code&gt;/blog/some-guide-2025&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The route isn't prerendered, so Vercel serves the app shell — homepage-ish HTML, status &lt;code&gt;200&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Then&lt;/em&gt; the JavaScript would run and redirect — but the crawler has already been handed a 200 with the wrong content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The redirect lives inside the JavaScript. The crawler's verdict is formed before the JavaScript runs. So Google sees a 200 response whose body is the homepage, served at a URL that's supposed to be a specific blog post — a page that resolves to nothing meaningful. &lt;strong&gt;Soft 404.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The other two had no redirect at all and went straight to the shell fallback — same outcome by a more direct route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A client-side redirect is invisible to a crawler that judges the first response.&lt;/strong&gt; If you want Google to treat a URL as moved, the move has to happen in the response itself, before any JavaScript: a real server-side 3xx.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix is to do the redirect at the edge, not in the app. On Vercel that's a &lt;code&gt;redirects&lt;/code&gt; entry in &lt;code&gt;vercel.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/blog/old-slug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/blog/new-slug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permanent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"permanent": true&lt;/code&gt; emits a 308 (Vercel's permanent redirect), which Google treats the same as a 301. Now the &lt;em&gt;first&lt;/em&gt; response Googlebot gets is "this moved, permanently, here" — no shell, no JavaScript, no ambiguity. The &lt;code&gt;www&lt;/code&gt; variant was already handled by an apex-redirect rule, so once the apex path resolved correctly, the &lt;code&gt;www&lt;/code&gt; one chained into it.&lt;/p&gt;

&lt;p&gt;I added server-side redirects for the two that had none, pointed both old duplicates at the canonical post, and the client-side &lt;code&gt;&amp;lt;Navigate&amp;gt;&lt;/code&gt; routes became harmless fallbacks behind the real ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twist: most of them were already fixed
&lt;/h2&gt;

&lt;p&gt;Here's the part that saved me a pile of unnecessary work — and that I almost skipped.&lt;/p&gt;

&lt;p&gt;Before "fixing" the three that already had server-side redirects, I checked &lt;em&gt;when Google last crawled them&lt;/em&gt;. The URL Inspection API returns &lt;code&gt;lastCrawlTime&lt;/code&gt;. Every one of those dates was &lt;strong&gt;weeks before&lt;/strong&gt; the server-side redirects had shipped. The pages weren't broken anymore. Google's report was a snapshot from the last time it looked, and it simply hadn't looked again.&lt;/p&gt;

&lt;p&gt;GSC statuses are not live. They're the result of the most recent crawl, which can be a month stale on a low-traffic site. Before you re-fix something the report calls broken, check &lt;code&gt;lastCrawlTime&lt;/code&gt; — you may be debugging a problem that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Submission is not a fix
&lt;/h2&gt;

&lt;p&gt;The instinct, once the redirects were in, was to "submit the pages to Google." But you don't submit a redirect &lt;em&gt;source&lt;/em&gt; — it's not content, it's a signpost. The Indexing API is for telling Google a real page changed. For redirected URLs, the correct lever is &lt;strong&gt;GSC → "Validate Fix"&lt;/strong&gt; on the soft-404 report, which re-queues the crawl. The only thing worth submitting is the redirect &lt;em&gt;targets&lt;/em&gt; — the live pages — so Google freshens those.&lt;/p&gt;

&lt;p&gt;Resubmitting a URL Google already crawled doesn't change Google's mind. It just asks the same question again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other bucket: "crawled, not indexed"
&lt;/h2&gt;

&lt;p&gt;Separately, a larger batch of pages on the same site had been sitting in &lt;strong&gt;"Crawled – currently not indexed"&lt;/strong&gt; — and around two dozen of them cleared into the index over the same period. It's tempting to lump that together with the soft-404 fix, but it's a different problem with a different cause.&lt;/p&gt;

&lt;p&gt;Soft 404 is a &lt;em&gt;technical&lt;/em&gt; verdict: wrong response, fix the response. "Crawled, not indexed" is a &lt;em&gt;quality&lt;/em&gt; verdict: Google fetched a real page and chose not to index it. You don't move that with redirects or resubmission — you move it with better content (the prerender layer putting real HTML in front of the crawler) and internal links that give the page a reason to matter. Those recoveries were the delayed payoff of content and linking work from weeks earlier, not anything I did that day.&lt;/p&gt;

&lt;p&gt;Keeping the two buckets separate matters, because the fixes don't transfer. A 308 will never rescue a thin page, and a content rewrite will never fix a client-side redirect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;200&lt;/code&gt; status doesn't mean Google sees a real page. Soft 404 is the body contradicting the status line.&lt;/li&gt;
&lt;li&gt;On an SPA, any route that isn't prerendered falls through to the app shell — a 200 full of the wrong content. That's a soft-404 factory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-side redirects don't exist as far as a crawler is concerned.&lt;/strong&gt; Anything Google should treat as moved needs a server-side 3xx, before the JavaScript.&lt;/li&gt;
&lt;li&gt;GSC reports are stale snapshots. Check &lt;code&gt;lastCrawlTime&lt;/code&gt; before re-fixing.&lt;/li&gt;
&lt;li&gt;Submission re-asks the question; it doesn't change the answer. Use "Validate Fix" for redirects; submit only live targets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The redirects had been working the entire time — in the one place that couldn't see them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>react</category>
      <category>vercel</category>
    </item>
    <item>
      <title>Migrating Off OpenClaw Without Downtime — and the Offset That Made Hermes Look Dead</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sun, 14 Jun 2026 10:19:10 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/migrating-off-openclaw-without-downtime-and-the-offset-that-made-hermes-look-dead-2gic</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/migrating-off-openclaw-without-downtime-and-the-offset-that-made-hermes-look-dead-2gic</guid>
      <description>&lt;p&gt;A while back I wrote a &lt;a href="https://tedagentic.com/posts/openclaw-vs-hermes-agent" rel="noopener noreferrer"&gt;comparison of OpenClaw and Hermes&lt;/a&gt; — two open-source, self-hosted AI agents I run on the same bare-metal box, both wired to Telegram. The verdict was that they're complementary: OpenClaw as the dependable gateway for scheduled delivery, Hermes as the agent that builds context over time. Run both, I said. Don't choose.&lt;/p&gt;

&lt;p&gt;Then I actually lived with both for a few more weeks, and the verdict started to move.&lt;/p&gt;

&lt;p&gt;OpenClaw is &lt;strong&gt;gateway-first&lt;/strong&gt;: a messaging hub that runs plugins, models, and cron delivery on a schedule you define. Hermes is &lt;strong&gt;agent-first&lt;/strong&gt;: the messaging is just how you reach it, and the point is an agent that accumulates knowledge and needs less steering each time. On paper that made OpenClaw the better runtime for my scheduled jobs.&lt;/p&gt;

&lt;p&gt;The migration started for a plainer reason than "one is better": I'd come to trust Hermes more with anything long-running. When I handed my OpenClaw setup a task with real weight, it would acknowledge it and then go quiet — the agent loop hitting a timeout and dropping the work before it finished. That isn't a verdict on the project; it's a mismatch between how I had it configured and the load I was putting on it. But trust is trust, and mine had moved.&lt;/p&gt;

&lt;p&gt;Hermes, on the same machine, on a &lt;em&gt;free&lt;/em&gt; OpenRouter model, just finishes. It holds the task, retries, and delivers — because the persistence is in the harness, not the model. That's the whole thing: the model matters less than whether the system around it gives up.&lt;/p&gt;

&lt;p&gt;So I decided to migrate. But not the way you'd think.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: don't turn anything off
&lt;/h2&gt;

&lt;p&gt;The constraint I set for myself was that OpenClaw keeps running, untouched, the entire time. No big-bang switch. Two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's my fallback.&lt;/strong&gt; If the new path has a blind spot I haven't seen, I don't want to discover it by losing notifications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I want to compare.&lt;/strong&gt; Running both in parallel is the only way to see &lt;em&gt;where&lt;/em&gt; each one lags before I commit. You don't get that from a one-way cutover.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the plan became: deliver everything through Hermes &lt;em&gt;in parallel&lt;/em&gt; with OpenClaw for a couple of weeks, watch them side by side, then retire OpenClaw's delivery one job at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I underestimated: there wasn't one delivery path. There were four.
&lt;/h2&gt;

&lt;p&gt;My notification stack is a pile of cron jobs — site monitors, system-health checks, morning summaries, scheduled reports. The important thing about them is that &lt;strong&gt;the agent never touches them.&lt;/strong&gt; They're pure notifiers: each one computes a result and pushes it straight to Telegram. The agent only gets involved when I actually ask it something.&lt;/p&gt;

&lt;p&gt;I assumed they all delivered the same way. They didn't. When I went looking, there were &lt;strong&gt;four&lt;/strong&gt; different mechanisms across ~39 delivery points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scripts shelling out to the OpenClaw CLI&lt;/li&gt;
&lt;li&gt;Node monitors hitting the Telegram Bot API directly&lt;/li&gt;
&lt;li&gt;A shared bash helper used by a few jobs&lt;/li&gt;
&lt;li&gt;One Python script posting straight to the API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There was no single switch to flip. Which is exactly why a clean cutover would have been a bad idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mirror pattern
&lt;/h2&gt;

&lt;p&gt;Instead of rerouting anything, I made the new delivery &lt;strong&gt;purely additive&lt;/strong&gt;. One control script — &lt;code&gt;hermes_mirror.sh&lt;/code&gt; — and every delivery point gets &lt;em&gt;one&lt;/em&gt; extra line that calls it in the background:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bash jobs: &lt;code&gt;hermes_mirror.sh "$MSG" &amp;amp;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Python jobs: &lt;code&gt;subprocess.Popen([...])&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Node jobs: &lt;code&gt;child_process.spawn(...)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The helper waits a moment, then sends the same message through Hermes. The original OpenClaw send is never modified, never wrapped, never moved. If the mirror errors or hangs, the existing delivery already happened — it cannot be affected. And because everything funnels through that one script, there's a single kill switch: set &lt;code&gt;ENABLED=0&lt;/code&gt; and &lt;em&gt;all&lt;/em&gt; mirroring stops at once. One offset value, one log, one place to reason about.&lt;/p&gt;

&lt;p&gt;That additive property is the whole safety story. I wasn't migrating the system. I was running a second, shadow system next to it and watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The offset that made it look broken
&lt;/h2&gt;

&lt;p&gt;Here's the part that actually taught me something.&lt;/p&gt;

&lt;p&gt;I didn't want the two copies of every notification landing in the same second — that's noisy and makes them hard to tell apart. So I set the mirror to wait &lt;strong&gt;three minutes&lt;/strong&gt; before sending. Comfortable separation. Seemed obviously correct.&lt;/p&gt;

&lt;p&gt;Then I ran the first real test. Fired a notification. It showed up on the old bot. The new one… nothing. I sat there watching an empty chat, fully convinced the Hermes path had failed.&lt;/p&gt;

&lt;p&gt;It hadn't. It was running exactly as designed — three minutes behind. The copy landed right on schedule, 180 seconds later, while I was already digging through logs looking for a bug that didn't exist.&lt;/p&gt;

&lt;p&gt;The lesson stuck: &lt;strong&gt;in a parallel migration, "late" reads as "broken."&lt;/strong&gt; A safety margin I added to keep things tidy became a false failure signal, because the only way I had to judge the new system was &lt;em&gt;did the message show up when I expected it.&lt;/em&gt; Three minutes of silence and your brain files it under "dead."&lt;/p&gt;

&lt;p&gt;But the delay itself wasn't the mistake. The mistake was adding a delay without adding any signal that the delay was &lt;em&gt;intentional&lt;/em&gt;. If a mirrored notification is meant to land three minutes later, something should say so — a log line, a status ping, anything that separates "received, waiting" from "never arrived." Without that, silence and failure are indistinguishable. &lt;strong&gt;A safety mechanism with no observability doesn't make a system safer; it makes it ambiguous&lt;/strong&gt; — and ambiguity, in a migration, is its own kind of failure.&lt;/p&gt;

&lt;p&gt;I cut the offset to &lt;strong&gt;20 seconds&lt;/strong&gt;. Long enough that the two copies never collide, short enough that the new one clearly arrives &lt;em&gt;with&lt;/em&gt; the original instead of looking like it got lost. Same mechanism, completely different read on whether it's working. (The better fix is still on the list: have the mirror log an explicit "received, holding 20s" line, so the wait is something you can &lt;em&gt;see&lt;/em&gt;, not infer.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The principles that fell out of it
&lt;/h2&gt;

&lt;p&gt;By the time the offset was sorted, the migration had quietly handed me its own rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never modify the primary delivery path first.&lt;/strong&gt; The thing that works keeps working, untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the new path alongside the old — purely additive.&lt;/strong&gt; It can fail without taking anything down with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep one global kill switch.&lt;/strong&gt; A single place to stop everything the moment it misbehaves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make delayed delivery &lt;em&gt;visibly&lt;/em&gt; delayed, not silently delayed.&lt;/strong&gt; A wait with no signal is indistinguishable from a failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove the old path only after watching a full execution cycle&lt;/strong&gt; — including the jobs that only fire weekly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of them are clever. They're just what's left after you stop assuming the replacement works and start making it prove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha in automating the edit
&lt;/h2&gt;

&lt;p&gt;Wiring one line into ~20 Python scripts, I wrote a small injector instead of editing each by hand. It dropped the mirror call in as the first line of each delivery function. Clean — except one script imported its dependency &lt;em&gt;inside&lt;/em&gt; the function, &lt;strong&gt;below&lt;/strong&gt; where my line now sat. So the injected call referenced a module that wasn't imported yet. Instant &lt;code&gt;NameError&lt;/code&gt;, only in that one file.&lt;/p&gt;

&lt;p&gt;Blind codemods across a heterogeneous pile of scripts will find the one file that doesn't match your mental model. Compile-check everything after, not just the ones you expect to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I expected to be hard and wasn't
&lt;/h2&gt;

&lt;p&gt;I assumed I'd have to port OpenClaw's scheduled &lt;em&gt;agent&lt;/em&gt; jobs — the ones where it actually reasons, not just delivers. Turned out they were all already disabled, superseded long ago by plain deterministic scripts. The only thing OpenClaw was still genuinely doing for me on a schedule was acting as the mailman. The agentic work I thought I'd be migrating didn't exist anymore. The real dependency was just delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;Both agents now deliver the full stream in parallel, Hermes trailing each original by 20 seconds. OpenClaw is untouched and still my fallback. The plan is to watch a complete cycle — including the jobs that only fire weekly — and then start removing OpenClaw's send per-job, one at a time, only once I've seen each one deliver cleanly through Hermes.&lt;/p&gt;

&lt;p&gt;The migration isn't "done" — and that's the point. The mirror pattern turned it from a decision into an observation exercise. I never had to ask whether Hermes was ready; I could watch it prove it, one notification at a time, with the old system holding the floor the whole way.&lt;/p&gt;

&lt;p&gt;That's the part I'll carry to the next one. When you're replacing a live system you don't trust yet, the move isn't to cut over and hope. It's to run the replacement in the open, right next to the thing it's replacing, and let it earn the handoff — visibly, on a schedule you can watch, with nothing torn out until it has.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>GEO: What Generative Engine Optimization Actually Means for Your Content</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Thu, 11 Jun 2026 08:30:10 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/geo-what-generative-engine-optimization-actually-means-for-your-content-4em1</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/geo-what-generative-engine-optimization-actually-means-for-your-content-4em1</guid>
      <description>&lt;p&gt;Search didn't break. It restructured. The page that ranked #1 for an informational query now sits below an AI-generated summary that pulls from it — and the user never scrolls down.&lt;/p&gt;

&lt;p&gt;That's the GEO problem in one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed in the SERP
&lt;/h2&gt;

&lt;p&gt;Old SERP for most informational queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────┐
│  [Ads]                              │
│  1. example.com/answer              │
│  2. another-site.com/guide          │
│  3. wikipedia.org/wiki/topic        │
│  4. ...                             │
└─────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New SERP for the same query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────┐
│  [AI Overview]                      │
│   └─ synthesized answer             │
│   └─ 3-5 citation links (collapsed) │
│                                     │
│  [People Also Ask]                  │
│   └─ expands inline                 │
│                                     │
│  1. example.com/answer  ← you       │
│  2. another-site.com/guide          │
└─────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user gets an answer before they see your link. CTR drops. Impressions stay the same or grow. That gap is the GEO signal.&lt;/p&gt;

&lt;p&gt;On one site I run, impressions on an informational page grew 4x over three months while CTR dropped below 0.5% — the query cluster started triggering AI Overviews during that period. The page didn't lose rankings. It lost the click.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO vs GEO — What Each Optimizes For
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;SEO&lt;/th&gt;
&lt;th&gt;GEO&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Target&lt;/td&gt;
&lt;td&gt;Ranking position&lt;/td&gt;
&lt;td&gt;Citation in AI summary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signal&lt;/td&gt;
&lt;td&gt;Backlinks, authority, E-E-A-T&lt;/td&gt;
&lt;td&gt;Structure, clarity, entity match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Win condition&lt;/td&gt;
&lt;td&gt;User clicks your link&lt;/td&gt;
&lt;td&gt;AI quotes your content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metric&lt;/td&gt;
&lt;td&gt;Clicks, CTR&lt;/td&gt;
&lt;td&gt;Impressions → brand recall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk&lt;/td&gt;
&lt;td&gt;Algorithm update&lt;/td&gt;
&lt;td&gt;AI answers the query fully&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GEO and SEO aren't competing strategies. GEO is what happens to SEO when the SERP adds a layer above organic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Query Types Are Most Exposed
&lt;/h2&gt;

&lt;p&gt;Not every query gets an AI Overview. The exposure depends on query type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HIGH GEO EXPOSURE
─────────────────────────────────────────
  Informational   "what is X"
                  "how does X work"
                  "X explained"
                  "X rules / laws / limits"

MEDIUM GEO EXPOSURE
─────────────────────────────────────────
  Comparison      "X vs Y"
                  "best X for Y"
                  "X alternatives"

LOW GEO EXPOSURE
─────────────────────────────────────────
  Transactional   "buy X"
                  "X near me"
                  "book X in [city]"
                  "X price"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transactional and local queries rarely trigger AI Overviews. Google still routes those to maps, commerce, and organic listings. That's where clicks live.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GEO-Optimized Content Looks Like
&lt;/h2&gt;

&lt;p&gt;AI systems appear to favor content that answers cleanly and early. The structure matters more than the word count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not this:&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;Introduction paragraph...
Background on the topic...
History and context...
What experts say...
[Actual answer buried at paragraph 6]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;This:&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;[Direct answer in first 2 sentences]

## Key points
- Fact with number
- Fact with source signal ("as of 2026...")
- Specific limit, rule, or definition

[Supporting detail below]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI Overview pulls from the top of the page. If your answer is buried, a cleaner competitor gets cited instead — even if you outrank them.&lt;/p&gt;

&lt;p&gt;Other signals that increase citation likelihood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Defined terms in headings (&lt;code&gt;##&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Numbered lists with specific values (not vague)&lt;/li&gt;
&lt;li&gt;Dates and freshness markers&lt;/li&gt;
&lt;li&gt;Tables for comparison content&lt;/li&gt;
&lt;li&gt;No filler text between the H1 and the answer&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Downstream Intent Play
&lt;/h2&gt;

&lt;p&gt;Here's the strategic shift GEO forces: stop trying to own queries where AI answers the whole thing. Own what comes next.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User query: "what are the cannabis limits in Colorado"
     │
     ▼
AI Overview answers it fully
     │
     ▼
User now knows the limit — and wants to act on it
     │
     ├──► "420-friendly hotels in Colorado"      ← transactional
     ├──► "cannabis tours in Denver"             ← transactional
     └──► "where to buy in [city]"               ← local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The informational query feeds intent. The downstream query converts. GEO-aware content strategy maps the full path — not just the definition page, but every action a user might take after getting the answer.&lt;/p&gt;

&lt;p&gt;If your site only has the definition page, AI Overview cuts the journey short. If you have the definition page &lt;em&gt;and&lt;/em&gt; the action pages linked from it, you capture both the citation signal and the conversion traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical GEO Checklist
&lt;/h2&gt;

&lt;p&gt;Content structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Direct answer in the first 2 sentences&lt;/li&gt;
&lt;li&gt;[ ] No more than one paragraph before the first heading&lt;/li&gt;
&lt;li&gt;[ ] Key facts in lists or tables, not buried in prose&lt;/li&gt;
&lt;li&gt;[ ] Dates and specifics (not "recently" — use the year)&lt;/li&gt;
&lt;li&gt;[ ] Headings match the exact phrasing of common questions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Internal linking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every informational page links to a downstream action page&lt;/li&gt;
&lt;li&gt;[ ] Transactional pages don't depend on informational traffic alone&lt;/li&gt;
&lt;li&gt;[ ] Related guides section at the bottom of every post&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Monitoring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Track impressions vs clicks separately — divergence signals AI capture&lt;/li&gt;
&lt;li&gt;[ ] Flag pages where impressions grow but CTR drops below 1%&lt;/li&gt;
&lt;li&gt;[ ] Check which queries trigger AI Overviews for your target keywords&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Honest Summary
&lt;/h2&gt;

&lt;p&gt;GEO doesn't replace SEO work. Rankings still matter for non-AI-captured queries, and being cited in an AI Overview still requires ranking well enough to be in Google's index pull.&lt;/p&gt;

&lt;p&gt;What GEO changes is the &lt;em&gt;goal&lt;/em&gt; for different content types. Informational pages: optimize for citation and downstream linking, not for direct clicks. Transactional pages: optimize for clicks, those are still yours to win.&lt;/p&gt;

&lt;p&gt;The operators who will get hurt are the ones with sites built entirely on informational content with no action layer underneath. The ones who built the full funnel — definition → comparison → booking — are fine.&lt;/p&gt;

&lt;p&gt;Build the full funnel.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Wired Google Search Console to Telegram on Day One</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Thu, 11 Jun 2026 08:30:03 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/how-i-wired-google-search-console-to-telegram-on-day-one-2k82</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/how-i-wired-google-search-console-to-telegram-on-day-one-2k82</guid>
      <description>&lt;p&gt;Most people check Google Search Console by logging in, navigating to the property, clicking through the date filters, and reading off numbers. That works — but you only do it when you remember, and you only see what you think to look for.&lt;/p&gt;

&lt;p&gt;This is a different approach. The GSC data comes to you, every morning, in the same Telegram thread as every other system alert. No browser, no login, no forgetting. And because it's API-driven, you pull exactly what you need — top queries, top pages, position changes — formatted the way you want it, not the way Google's UI presents it.&lt;/p&gt;

&lt;p&gt;I wired this up on day three of launching tedagentic.com — before a single organic click. The &lt;a href="https://tedagentic.com/posts/how-to-set-up-local-ai-agent" rel="noopener noreferrer"&gt;Telegram delivery layer was already in place&lt;/a&gt; from the agent setup. Adding GSC to it was the natural next step: one more data source piped into the same channel.&lt;/p&gt;

&lt;p&gt;Building it before any traffic exists means the baseline is captured from zero. When the first impression shows up, it's logged. When something drops later, there's something to measure against. Most monitoring gets built reactively — after something falls. This flips that.&lt;/p&gt;

&lt;p&gt;This is what I built and how it works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google Search Console API
        ↓
  Node.js script (monitor.js)
        ↓
  cron (8:25am daily)
        ↓
  Telegram bot → phone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No dashboard. No third-party service. No browser required. The briefing arrives in the same Telegram thread as every other system alert — rankings, RAM, errors, competitor movements. One channel, everything piped in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: GSC service account
&lt;/h2&gt;

&lt;p&gt;I already had a Google service account from an earlier project. If you're starting from scratch, the setup is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://console.cloud.google.com" rel="noopener noreferrer"&gt;console.cloud.google.com&lt;/a&gt; → create a project&lt;/li&gt;
&lt;li&gt;Enable the &lt;strong&gt;Google Search Console API&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;IAM &amp;amp; Admin → Service Accounts → Create service account&lt;/li&gt;
&lt;li&gt;Download the JSON credentials key&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The credentials file looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"service_account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-project-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"client_email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-account@your-project.iam.gserviceaccount.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"private_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save it somewhere secure. Mine lives at &lt;code&gt;~/.gsc-credentials.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then go to Google Search Console, open your property, and add the &lt;code&gt;client_email&lt;/code&gt; as a user with &lt;strong&gt;Full&lt;/strong&gt; permission. The service account won't show in the autocomplete — paste the email directly and save.&lt;/p&gt;

&lt;p&gt;One thing to watch: GSC doesn't always confirm the save visually. Go back to Users and permissions and verify the email is listed before moving on. If it's not there, add it again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: The monitor script
&lt;/h2&gt;

&lt;p&gt;The script pulls three things from the Search Console API: 7-day totals, top pages, and top queries. Then formats it and sends to Telegram.&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;googleapis&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;https&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https&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;GSC_SITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sc-domain:yourdomain.com&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;CREDENTIALS_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/home/user/.gsc-credentials.json&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;TELEGRAM_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YOUR_BOT_TOKEN&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;TELEGRAM_CHAT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YOUR_CHAT_ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestBody&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;res&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;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchanalytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GSC_SITE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestBody&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;run&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;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GoogleAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;keyFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CREDENTIALS_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.googleapis.com/auth/webmasters.readonly&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;getClient&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;sc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;searchconsole&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// GSC has a ~3-day data lag — pull days 3-9 ago as "last 7 days"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&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="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="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;T&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&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="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="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;T&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;rowLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;rowLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;fieldName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;impressions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sortOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DESCENDING&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;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;rowLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;fieldName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;impressions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sortOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DESCENDING&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalClicks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&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;r&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clicks&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalImpr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&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;r&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;impressions&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avgPos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&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;r&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;—&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`📡 tedagentic.com — &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="nf"&gt;toDateString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`Clicks: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;totalClicks&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; | Impressions: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;totalImpr&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; | Avg pos: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;avgPos&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Top queries:\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;queries&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keys&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="s2"&gt;" — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;impressions&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; impr | pos &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&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;`&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="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Queries: none yet`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&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 full script with error handling and page breakdown is at &lt;code&gt;seo-monitor/tedagentic/monitor.js&lt;/code&gt; in the repo.&lt;/p&gt;

&lt;p&gt;Two things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 3-day lag.&lt;/strong&gt; GSC data isn't real-time — it runs 2-3 days behind. If you pull "yesterday," you'll get empty rows. The script accounts for this by offsetting: &lt;code&gt;end = today - 3 days&lt;/code&gt;, &lt;code&gt;start = end - 7 days&lt;/code&gt;. This is why the date window in the Telegram message will always look 3 days behind your actual calendar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Empty rows aren't errors.&lt;/strong&gt; A new site will return zero rows from the API for days or weeks. The script handles this cleanly — if &lt;code&gt;rows&lt;/code&gt; is empty it reports "none yet" rather than crashing. Early on that's most of what you'll see.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Cron
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;25 8 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /home/aiserver/seo-monitor &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; node tedagentic/monitor.js &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /home/aiserver/seo-monitor/tedagentic_monitor.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;8:25am daily. Runs after the other site monitors (7am–8:20am) so alerts don't pile up at the same time. The log captures everything — errors, output, the full run — so if a briefing doesn't arrive in Telegram I can check what happened without re-running anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  What day one looks like
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0aqwyb4pmj3zf9eqfvsx.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%2F0aqwyb4pmj3zf9eqfvsx.jpg" alt="Telegram briefing from tedagentic.com GSC monitor — day one, all zeros"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Zero clicks. Zero impressions. No queries. No pages.&lt;/p&gt;

&lt;p&gt;That's the point.&lt;/p&gt;

&lt;p&gt;This isn't a failure state — it's the starting gun. The monitoring stack is live before the data exists. When the first impression shows up in GSC, it'll be logged. When the first query appears, I'll see it in the morning briefing the same day it lands. When something drops later, I'll have the baseline to measure against.&lt;/p&gt;

&lt;p&gt;Most SEO monitoring setups are built reactively — something drops, you build monitoring to catch the next drop. Building it before any traffic exists flips that. You get the full picture from zero.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The current script is a daily briefing — totals and top items, no intelligence layer. The next step is adding an agent commentary pass: the raw GSC data goes to the LLM, which adds a short interpretation. Not a dashboard, not a report — a one-line read from the agent on whether the numbers look normal, surprising, or worth attention.&lt;/p&gt;

&lt;p&gt;That's the difference between monitoring and an AI agent doing monitoring. The script already runs the former. Part 3 covers wiring in the latter.&lt;/p&gt;

&lt;p&gt;The same agent that delivers this briefing can now &lt;a href="https://tedagentic.com/posts/openclaw-x-skill" rel="noopener noreferrer"&gt;post to X directly from Telegram&lt;/a&gt; — the infrastructure compounds as each piece connects.&lt;/p&gt;

&lt;p&gt;For now: the baseline exists. The system is watching. The data will come.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>automation</category>
      <category>telegram</category>
      <category>node</category>
    </item>
    <item>
      <title>The Prerender Was Running. The Pages Were Still Empty.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 10 Jun 2026 15:02:13 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/the-prerender-was-running-the-pages-were-still-empty-2cc8</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/the-prerender-was-running-the-pages-were-still-empty-2cc8</guid>
      <description>&lt;p&gt;In &lt;a href="https://tedagentic.com/posts/why-astro-over-lovable" rel="noopener noreferrer"&gt;Astro vs Lovable for SEO&lt;/a&gt; I wrote about a client site dropping out of Google's index because Googlebot was getting empty HTML shells from a CSR React app. The fix was a custom prerender layer — a build-time script that injects real content into the static HTML before the JS bundle runs.&lt;/p&gt;

&lt;p&gt;I built it, deployed it, and moved on. The prerender was in the pipeline. I assumed it was working.&lt;/p&gt;

&lt;p&gt;Six months later I checked GSC on the site's four main directory pages. Zero impressions. Not low — zero. Across the entire period.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pipeline was supposed to do
&lt;/h2&gt;

&lt;p&gt;The build command in &lt;code&gt;package.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vite build &amp;amp;&amp;amp; node scripts/prerender.mjs"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After Vite builds the React bundle, the prerender script runs. It fetches live data from Supabase, then walks every route and injects real content — titles, canonicals, descriptions, and actual listings — directly into the HTML files in &lt;code&gt;dist/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────┐
│                   INTENDED PIPELINE                      │
├─────────────────────────────────────────────────────────┤
│                                                          │
│   vite build          →   dist/ HTML shells             │
│        │                                                 │
│        ▼                                                 │
│   prerender.mjs       →   fetch Supabase data           │
│        │                       │                        │
│        │              ┌────────▼────────┐               │
│        │              │  category A     │               │
│        │              │  category B     │               │
│        │              │  category C     │               │
│        │              │  category D     │               │
│        │              └────────┬────────┘               │
│        │                       │                        │
│        ▼                       ▼                        │
│   inject into dist/   →   real HTML content             │
│                                                          │
│   Googlebot sees: listings, titles, descriptions        │
└─────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The theory was sound. The implementation had a silent failure mode I never tested for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;The prerender script fetched data like this:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchListings&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_SUPABASE_URL&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_SUPABASE_PUBLISHABLE_KEY&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;url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;  ⚠ Supabase env vars missing — skipping live fetch&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="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... fetch from Supabase&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the env vars weren't present, it logged a warning and returned an empty array. Then the HTML builder received an empty array and used its fallback:&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;buildListingsHtml&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="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;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="s1"&gt;&amp;lt;p&amp;gt;Browse our verified listings below.&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... build real listing HTML&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prerender ran. It produced output. Every page got a title, a canonical URL, and a meta description. But the body content was a single generic sentence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────┐
│                  WHAT ACTUALLY HAPPENED                  │
├─────────────────────────────────────────────────────────┤
│                                                          │
│   Vercel build starts                                    │
│        │                                                 │
│        ▼                                                 │
│   prerender.mjs runs                                     │
│        │                                                 │
│        ▼                                                 │
│   process.env.VITE_SUPABASE_URL  →  undefined           │
│        │                                                 │
│        ▼                                                 │
│   ⚠ warning logged, empty array returned               │
│        │                                                 │
│        ▼                                                 │
│   buildListingsHtml([])  →  fallback placeholder        │
│        │                                                 │
│        ▼                                                 │
│   dist/category-a/index.html injected with:             │
│   &amp;lt;p&amp;gt;Browse our verified listings below.&amp;lt;/p&amp;gt;            │
│                                                          │
│   Googlebot sees: one sentence, no listings             │
└─────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build logs showed the warning. I wasn't reading build logs. The deploy succeeded. Vercel reported green. The pages looked correct when I opened them in a browser — React hydrated immediately and replaced the placeholder with live data from Supabase. Only a Googlebot crawl or a raw &lt;code&gt;curl&lt;/code&gt; would have caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding it
&lt;/h2&gt;

&lt;p&gt;One of the four directories was getting consistent clicks in GSC — not many, but steady. The others showed nothing at all. That partial signal was actually what made the problem easy to dismiss for months. One section performing feels like evidence the system is working. It isn't — it just means one placeholder happened to have enough content to be useful to Google, while the others didn't.&lt;/p&gt;

&lt;p&gt;The directory that was ranking had a more descriptive static placeholder — a full sentence explaining what the section contained. The others had a single generic line. Google indexed what it could work with and ignored the rest.&lt;/p&gt;

&lt;p&gt;Once I looked at the pattern across all four, the gap was obvious. I ran a direct check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/category-a"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"ssr-prerender"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"ssr-prerender"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"font-family:sans-serif;..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Category A Directory&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Browse our verified listings below.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Site Name&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt; — Directory&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One sentence. No listing names, no descriptions, no links to individual detail pages. Three of the four directories looked exactly like this.&lt;/p&gt;

&lt;p&gt;The prerender had been injecting this placeholder on every single deploy since the site launched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: why it works locally but fails on Vercel
&lt;/h2&gt;

&lt;p&gt;Vercel does not read your local &lt;code&gt;.env&lt;/code&gt; file during builds. It uses environment variables configured in the Vercel dashboard under Project Settings → Environment Variables.&lt;/p&gt;

&lt;p&gt;The Supabase credentials existed locally. They were not in Vercel. The prerender script called &lt;code&gt;process.env.VITE_SUPABASE_URL&lt;/code&gt;, got &lt;code&gt;undefined&lt;/code&gt;, warned quietly, and continued.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────┐     ┌────────────────────────────┐
│      Local machine         │     │      Vercel build          │
├────────────────────────────┤     ├────────────────────────────┤
│                            │     │                            │
│  .env                      │     │  Environment Variables     │
│  VITE_SUPABASE_URL=...  ✓  │     │  (none configured)    ✗   │
│  VITE_SUPABASE_KEY=...  ✓  │     │                            │
│                            │     │  process.env.VITE_...      │
│  prerender works locally   │     │  → undefined               │
│                            │     │  → silent fallback         │
└────────────────────────────┘     └────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part that made it easy to miss: the prerender worked perfectly in local builds. &lt;code&gt;vite build &amp;amp;&amp;amp; node scripts/prerender.mjs&lt;/code&gt; locally produced real content because the &lt;code&gt;.env&lt;/code&gt; file was present. The failure only happened in the Vercel build environment, which I never tested directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Add env vars to Vercel.&lt;/strong&gt; In the Vercel dashboard: Settings → Environment Variables → add &lt;code&gt;VITE_SUPABASE_URL&lt;/code&gt; and &lt;code&gt;VITE_SUPABASE_PUBLISHABLE_KEY&lt;/code&gt; for all environments (Production, Preview, Development).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;VITE_SUPABASE_PUBLISHABLE_KEY&lt;/code&gt; is the Supabase anon key — designed to be public. Vercel warns that keys prefixed with &lt;code&gt;VITE_&lt;/code&gt; may be exposed to the browser. This is intentional; the anon key is already in every user's browser via the JS bundle. Row Level Security policies handle actual access control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Extend the prerender to cover all directories.&lt;/strong&gt; The original script only fetched one category. I refactored the fetch into a shared helper:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;supabaseFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_SUPABASE_URL&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_SUPABASE_PUBLISHABLE_KEY&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;url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;  ⚠ Supabase env vars missing — skipping live fetch&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="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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="s2"&gt;`&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="s2"&gt;/rest/v1/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?select=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;select&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;order=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;limit=200`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;apikey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&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="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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &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;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  ⚠ &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; fetch failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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;Each directory now has its own HTML builder that receives live data and produces real listings for Googlebot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────┐
│                    FIXED PIPELINE                        │
├─────────────────────────────────────────────────────────┤
│                                                          │
│   Vercel build                                           │
│        │                                                 │
│        ▼                                                 │
│   process.env.VITE_SUPABASE_URL  →  set ✓              │
│        │                                                 │
│        ▼                                                 │
│   supabaseFetch('table_a')  →  N listings               │
│   supabaseFetch('table_b')  →  N listings               │
│   supabaseFetch('table_c')  →  N listings               │
│   supabaseFetch('table_d')  →  N listings               │
│        │                                                 │
│        ▼                                                 │
│   each builder produces &amp;lt;ul&amp;gt; of real listings           │
│   with names, descriptions, prices, links               │
│        │                                                 │
│        ▼                                                 │
│   Googlebot sees: real content on first request         │
└─────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verifying prerender output
&lt;/h2&gt;

&lt;p&gt;After any prerender change, verify the output before assuming it worked:&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;# Check a directory page for real content&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://yoursite.com/your-directory"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"ssr-prerender"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 20

&lt;span class="c"&gt;# If you see a single generic sentence — the fetch failed&lt;/span&gt;
&lt;span class="c"&gt;# If you see &amp;lt;h3&amp;gt; listing names — it's working&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally, after a build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run build
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 20 &lt;span class="s1"&gt;'ssr-prerender'&lt;/span&gt; dist/your-directory/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the output is a placeholder, check whether env vars are available in the build context. Don't assume the warning in build logs is acceptable — treat it as a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this cost
&lt;/h2&gt;

&lt;p&gt;Six months of Googlebot crawling directory pages with no real content. Every crawl budget spent on those pages returned nothing indexable. The pages weren't penalised — they just didn't exist in Google's index.&lt;/p&gt;

&lt;p&gt;The framer-motion incident taught me to add synthetic monitoring for broken UI. This one taught me to verify build output directly, not just check that the script ran.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────┐
│               MONITORING GAPS, UPDATED                   │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  ✓ UI monitoring      →  Playwright, every 2h          │
│  ✓ GSC monitoring     →  rankings, index status         │
│  ✓ Build verification →  curl prerendered pages         │
│                            after every pipeline change   │
│                                                          │
│  Still blind:                                            │
│  ✗ Build log scanning  →  automated warning detection   │
│  ✗ Data freshness      →  confirm live data not stale   │
└─────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is running. The build log now shows row counts for every table fetch — real numbers, not zero. That's what it should have shown from day one.&lt;/p&gt;

</description>
      <category>vercel</category>
      <category>seo</category>
      <category>debugging</category>
      <category>javascript</category>
    </item>
    <item>
      <title>framer-motion v12 Broke My UI — And My Monitoring Never Saw It</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 10 Jun 2026 15:02:07 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/framer-motion-v12-broke-my-ui-and-my-monitoring-never-saw-it-3k5f</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/framer-motion-v12-broke-my-ui-and-my-monitoring-never-saw-it-3k5f</guid>
      <description>&lt;p&gt;I had 22 cron jobs running — ranking checks, error guards, page watchers, RAM monitors, Telegram briefings. None of them fired.&lt;/p&gt;

&lt;p&gt;A framer-motion v12 upgrade had been silently blocking navigation on all browsers and crashing the app entirely on Safari. I found it by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;Two symptoms, discovered in the same session:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Safari crash.&lt;/strong&gt; One page wouldn't load at all. Users saw: &lt;em&gt;"Something went wrong. Please refresh the page to continue."&lt;/em&gt; Refreshing didn't help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Click navigation broken on all browsers.&lt;/strong&gt; Interactive cards did nothing when clicked — they hovered correctly, scaled visually, but the click never fired.&lt;/p&gt;

&lt;p&gt;The error message came from the React error boundary in &lt;code&gt;App.tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;render&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasError&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="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"min-h-screen flex items-center justify-center"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Something went wrong&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Please refresh the page to continue.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Error boundaries are good practice — they prevent blank screens. But they swallow the actual error. Nothing in any log showed what crashed or when it started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: framer-motion v12 + WAAPI
&lt;/h2&gt;

&lt;p&gt;framer-motion v12 switched its animation engine to the &lt;strong&gt;Web Animations API (WAAPI)&lt;/strong&gt; by default. This broke things in two distinct ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safari crash&lt;/strong&gt; — Safari's WAAPI implementation is incomplete. Certain &lt;code&gt;element.animate()&lt;/code&gt; calls throw in Safari where Chrome and Firefox handle them fine. The unhandled error cascades into the error boundary, which hides the page behind the generic message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Click blocking on all browsers&lt;/strong&gt; — When &lt;code&gt;motion.div&lt;/code&gt; runs a &lt;code&gt;whileHover&lt;/code&gt; animation via WAAPI, the animation layer sits in the pointer event path and intercepts clicks before they reach the underlying &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt;. The hover works. The click doesn't.&lt;/p&gt;

&lt;p&gt;Both problems came from the same pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/some/route"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;whileHover&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.02&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    ...card content...
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Replace &lt;code&gt;whileHover&lt;/code&gt; on elements inside &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt; with CSS-only hover. Same visual result, no JS animation in the pointer event path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;whileHover&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.02&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

// After
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"transition-all duration-200 hover:scale-[1.02]"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entrance animations (&lt;code&gt;initial&lt;/code&gt; / &lt;code&gt;animate&lt;/code&gt;) were left in place — fade and slide transitions work correctly via WAAPI on all browsers including Safari. Only &lt;code&gt;whileHover&lt;/code&gt; inside interactive elements is the problem.&lt;/p&gt;

&lt;p&gt;Two files changed, ~10 lines. Navigation worked on first deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why monitoring missed it
&lt;/h2&gt;

&lt;p&gt;Here's what my stack was watching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────┐
│              WHAT I WAS MONITORING                  │
├─────────────────────────────────────────────────────┤
│  GSC Rankings      impressions, CTR, position drops │
│  Index Status      crawl errors, 4xx, deindex risk  │
│  Page Speed        Core Web Vitals, load time       │
│  Server Health     RAM, disk, process uptime        │
│  Telegram alerts   morning briefing, threshold hits │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│              WHAT I WASN'T MONITORING               │
├─────────────────────────────────────────────────────┤
│  Does the page actually render without JS errors?   │
│  Do navigation links work when clicked?             │
│  Is the error boundary showing instead of content?  │
└─────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GSC data lags 48–72 hours minimum. A broken page can still show stable impressions for days because crawlers hit the prerendered HTML shell — the static content is fine. The JS crash only happens when React hydrates in a real browser, which Googlebot doesn't fully replicate.&lt;/p&gt;

&lt;p&gt;The gap is &lt;strong&gt;synthetic monitoring&lt;/strong&gt; — an automated browser that visits pages, clicks things, and asserts on the result. I had zero of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the gap with Playwright
&lt;/h2&gt;

&lt;p&gt;Playwright is a headless browser automation library. Script it to visit URLs, interact with elements, and assert on what it finds.&lt;/p&gt;

&lt;p&gt;The new monitoring layer looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────┐     every 2h      ┌─────────────────────┐
│   cron job   │ ───────────────▶  │  ui_monitor.js      │
└──────────────┘                   │                     │
                                   │  Playwright opens   │
                                   │  real Chromium      │
                                   │                     │
                                   │  7 checks:          │
                                   │  • page loads       │
                                   │  • no error boundary│
                                   │  • clicks navigate  │
                                   └────────┬────────────┘
                                            │
                                   pass     │     fail
                                            │
                                   ─────────┼──────────▶  Telegram alert
                                            │
                                          log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 7 checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS: Homepage loads
PASS: Key blog page loads
PASS: Interactive guide loads
PASS: Navigation card click — route A
PASS: Navigation card click — route B
PASS: Laws page loads
PASS: Blog index loads
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each check opens a fresh Chromium context, loads the page, and verifies behaviour — not just HTTP 200, but that the error boundary isn't visible and that clicking a card navigates to the correct route.&lt;/p&gt;

&lt;p&gt;Telegram alert on failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🚨 UI Monitor — 1 check(s) failed

❌ Navigation card click — route A
   Error boundary visible after click

⏰ 2026-05-12T14:00:00.000Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One practical detail: the app had an age-verification modal that blocks all pointer events on first visit. The monitor bypasses it by injecting a localStorage key before page load via &lt;code&gt;addInitScript&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addInitScript&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;age-verified&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&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;Don't try to click through first-visit gates in tests — just set the state the app checks.&lt;/p&gt;

&lt;p&gt;Cron entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 */2 * * * node /path/to/ui_monitor.js &amp;gt;&amp;gt; /path/to/ui_monitor.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this doesn't cover yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebKit engine&lt;/strong&gt; — Playwright Chromium doesn't replicate Safari's JS engine. A WebKit-specific crash would still go undetected. Playwright has a &lt;code&gt;webkit&lt;/code&gt; browser option; that's next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authenticated flows&lt;/strong&gt; — forms and gated pages aren't tested&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance regressions&lt;/strong&gt; — slow pages aren't flagged&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full coverage isn't the goal at this stage. The goal was to go from zero synthetic monitoring to something. Seven checks every 2 hours is a meaningful step change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;External monitoring  →  what crawlers see
Synthetic monitoring →  what users experience
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They are not the same thing. A page can be indexed, ranking, and receiving impressions while being completely broken for every human who visits it.&lt;/p&gt;

&lt;p&gt;Adding Playwright took an afternoon. The cron runs automatically. The next time a dependency ships a breaking change — and it will — I'll know within 2 hours instead of weeks.&lt;/p&gt;

&lt;p&gt;This incident pushed me to think about monitoring gaps more broadly — if UI monitoring wasn't there, what else wasn't? The answer was search visibility, which led to &lt;a href="https://tedagentic.com/posts/automating-seo-monitoring-with-ai-agents" rel="noopener noreferrer"&gt;wiring GSC directly to Telegram&lt;/a&gt; before the site had a single organic click.&lt;/p&gt;

</description>
      <category>react</category>
      <category>debugging</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Set Up a Local AI Agent on Your Own Server</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 10 Jun 2026 15:02:01 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/how-to-set-up-a-local-ai-agent-on-your-own-server-330</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/how-to-set-up-a-local-ai-agent-on-your-own-server-330</guid>
      <description>&lt;p&gt;I run SEO campaigns across multiple sites, monitor rankings daily, and manage automation scripts on a homeserver. For a long time the AI layer was completely disconnected from all of it — I'd open a browser, paste in context, get a response, close the tab. Every session started from zero. No memory of what I was working on, no access to my data, no continuity between conversations.&lt;/p&gt;

&lt;p&gt;The problem isn't the models. The problem is the interface. A browser tab is a one-shot tool. You get a response and you leave. Nothing persists, nothing connects, and every time you come back you're rebuilding context from scratch.&lt;/p&gt;

&lt;p&gt;The fix was OpenClaw — a free, open source AI orchestrator that runs as a background daemon on your server and connects your agent to Telegram. I already used Telegram for monitoring alerts — ranking drops, RAM spikes, cron failures. Having the AI in the same app meant going from an alert to asking the agent to investigate it without switching tools. One app, one workflow — and the agent is always on.&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%2Fehcxdymzim7r61r1yhkg.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%2Fehcxdymzim7r61r1yhkg.jpg" alt="OpenClaw AI orchestrator" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What OpenClaw Is
&lt;/h2&gt;

&lt;p&gt;OpenClaw's official description is "multi-channel AI gateway with extensible messaging integrations." The mechanics are what matter.&lt;/p&gt;

&lt;p&gt;The gateway runs on local loopback at port 18789 on your machine. Every message you send through Telegram routes through it to the agent. The agent has a persistent workspace — files that carry context between sessions: who you are, what your projects are, what it's learned about how you work. That workspace loads into every conversation automatically.&lt;/p&gt;

&lt;p&gt;Most web AI interfaces are stateless — each session starts fresh. OpenClaw's agent accumulates context over time. The longer it runs, the more useful it gets.&lt;/p&gt;

&lt;p&gt;It supports multiple LLM providers out of the box: Anthropic, Google, OpenRouter, Ollama, and OpenAI. You're not locked into one model or one bill. I run a free OpenRouter model as my primary and route heavier tasks to Claude or Gemini when needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;I didn't run through this manually. I gave Claude Code the task over SSH — it handled the full install, configured the files, and flagged the two things that needed my input. Here's what the process looks like:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Install OpenClaw globally via npm
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; openclaw@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requires Node.js 22.16 or higher. Node 24 is recommended. I was on Node 22 and it worked fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Run onboarding with the daemon flag
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw onboard &lt;span class="nt"&gt;--install-daemon&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--install-daemon&lt;/code&gt; flag registers a systemd user service on Linux (launchd on macOS). After this, OpenClaw starts automatically on boot and stays running in the background.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Configure the main settings file
&lt;/h3&gt;

&lt;p&gt;The config lives at &lt;code&gt;~/.openclaw/openclaw.json&lt;/code&gt;. This is where Claude did the most work — channel settings, model providers, plugin allowlist. The key sections:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"telegram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"botToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR_BOT_TOKEN_HERE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dmPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pairing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"allowFrom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"YOUR_TELEGRAM_USER_ID"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"defaults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openrouter/minimax/minimax-m2.5:free"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"models"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"providers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ollama"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://127.0.0.1:11434"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"anthropic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.anthropic.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"openrouter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://openrouter.ai/api/v1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ollama"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anthropic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"google"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openrouter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"browser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"telegram"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Create a Telegram bot via BotFather
&lt;/h3&gt;

&lt;p&gt;This is the one step you do manually. Open Telegram, search &lt;code&gt;@BotFather&lt;/code&gt;, send &lt;code&gt;/newbot&lt;/code&gt;, follow the prompts. You get a bot token — paste it into &lt;code&gt;openclaw.json&lt;/code&gt; under &lt;code&gt;channels.telegram.botToken&lt;/code&gt;. Your Telegram user ID goes in &lt;code&gt;allowFrom&lt;/code&gt; — message &lt;code&gt;@userinfobot&lt;/code&gt; to find it.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Restart the daemon
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw daemon restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. Pair your Telegram account
&lt;/h3&gt;

&lt;p&gt;Send your bot a message. OpenClaw's default security model (&lt;code&gt;dmPolicy: "pairing"&lt;/code&gt;) means unknown senders get a pairing code before the agent responds. Approve it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw pairing approve telegram &amp;lt;code&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, the bot responds. Setup done.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; The &lt;code&gt;plugins.allow&lt;/code&gt; list must explicitly include &lt;code&gt;"telegram"&lt;/code&gt; or the channel silently fails to start — the daemon reports running but your bot won't respond. This catches people off guard because there's no error. The Ollama TUI inside OpenClaw also rewrites &lt;code&gt;openclaw.json&lt;/code&gt; when you change models, and it drops &lt;code&gt;telegram&lt;/code&gt; from the allowlist. If your bot goes quiet after a model change, that's why. Add &lt;code&gt;"telegram"&lt;/code&gt; back to &lt;code&gt;plugins.allow&lt;/code&gt; and restart the daemon.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Choosing a Model
&lt;/h2&gt;

&lt;p&gt;My primary is &lt;code&gt;openrouter/minimax/minimax-m2.5:free&lt;/code&gt; — handles general queries, completely free, good enough for most daily tasks. For anything that needs more reasoning I fall back to Claude Haiku 4.5 via the Anthropic API. For recurring automation like trend alerts I use &lt;code&gt;kimi-k2.5:cloud&lt;/code&gt; via Ollama running locally — no API cost at all.&lt;/p&gt;

&lt;p&gt;The free OpenRouter tier covers the majority of day-to-day use. You only route to paid models when the task demands it, which keeps the running cost close to zero most months.&lt;/p&gt;

&lt;p&gt;One thing to avoid: Gemini via the OpenAI-compatible endpoint returns 400 errors on this setup. Route it through the native Google provider in &lt;code&gt;openclaw.json&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Use It Day to Day
&lt;/h2&gt;

&lt;p&gt;Setting up OpenClaw is what prompted me to build out my SEO monitoring scripts properly. Once I had Telegram as a reliable delivery layer, it made sense to build scripts that push data directly there.&lt;/p&gt;

&lt;p&gt;The key design decision: scripts deliver raw GSC data straight to Telegram via the bot API — not through the OpenClaw agent. That's intentional. When I want accurate ranking numbers and click metrics, I don't want an AI in the middle summarizing or rounding figures. The script pulls from the API, formats it, and sends it. Exact data, no interpretation.&lt;/p&gt;

&lt;p&gt;The agent comes in on top of that. Once raw data lands in Telegram, I ask the agent to interpret it, spot patterns, or flag what to act on. That's where AI adds value — commentary and analysis, not data retrieval. It also keeps cost down since the agent only runs when I actually need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://tedagentic.com/posts/automating-seo-monitoring-with-ai-agents" rel="noopener noreferrer"&gt;Part 2: How I Wired Google Search Console to Telegram on Day One&lt;/a&gt; I show the full SEO monitoring workflow built on top of this — the scripts, the cron jobs, the Telegram alerts, and how the agent turns raw GSC data into actual decisions. That's the real case study.&lt;/p&gt;

&lt;p&gt;The foundation is what this post covers: a self-hosted agent, always on, connected to your data, living in the app you already use. Everything else builds on top of that — including &lt;a href="https://tedagentic.com/posts/openclaw-x-skill" rel="noopener noreferrer"&gt;posting to X directly from Telegram&lt;/a&gt; without touching a browser.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>selfhosted</category>
      <category>telegram</category>
      <category>automation</category>
    </item>
    <item>
      <title>Seven Days, Zero Impressions — One Wrong Sitemap URL</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 10 Jun 2026 03:46:40 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/seven-days-zero-impressions-one-wrong-sitemap-url-5cp0</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/seven-days-zero-impressions-one-wrong-sitemap-url-5cp0</guid>
      <description>&lt;p&gt;Seven days after launching this blog I had one page indexed: the homepage. Every post showed "URL is unknown to Google" in GSC's URL Inspection tool. Not crawled and rejected — unknown. Google had never seen them.&lt;/p&gt;

&lt;p&gt;The stack was solid. Astro static output, 100 across all Core Web Vitals, real content on every post, sitemap submitted on day one. There was no rendering problem, no thin content problem, no technical issue I could point to.&lt;/p&gt;

&lt;p&gt;I knew something was wrong. A site scoring 100 on CWV with real content should at minimum show impressions within a week even with no clicks. No signal at all meant Google wasn't reaching the posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I checked
&lt;/h2&gt;

&lt;p&gt;Pulled index status on every post via the GSC URL Inspection API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;verdict&lt;/th&gt;
&lt;th&gt;last crawled&lt;/th&gt;
&lt;th&gt;coverage state&lt;/th&gt;
&lt;th&gt;url&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;td&gt;2026-05-15&lt;/td&gt;
&lt;td&gt;Submitted and indexed&lt;/td&gt;
&lt;td&gt;/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/automating-seo-monitoring-with-ai-agents/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/claude-built-my-astro-blog/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/connecting-x-api-to-server/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/core-web-vitals-astro-case-study/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/framer-motion-safari-synthetic-monitoring/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/how-to-set-up-local-ai-agent/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/lovable-ssr-update/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/openclaw-x-skill/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/prerender-silent-failure/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEUTRAL&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;/posts/why-astro-over-lovable/&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Homepage indexed, every post never crawled. That pattern points to one thing — Google found the homepage but couldn't discover the content. Either internal links were broken or the sitemap wasn't being processed.&lt;/p&gt;

&lt;p&gt;Checked the sitemap submission in GSC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;URL:       https://www.tedagentic.com/sitemap.xml
Submitted: 2026-05-13
Downloaded: 2026-05-13
submitted: 37 | indexed: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. The sitemap was submitted at &lt;code&gt;https://www.tedagentic.com/sitemap.xml&lt;/code&gt;. The site canonicalises to &lt;code&gt;https://tedagentic.com/&lt;/code&gt; — no &lt;a href="http://www" rel="noopener noreferrer"&gt;www&lt;/a&gt;. Google downloaded the sitemap once, saw 37 URLs, indexed zero of them, and stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this breaks indexing
&lt;/h2&gt;

&lt;p&gt;The sitemap URL and your canonical domain need to match exactly. If your canonical is &lt;code&gt;tedagentic.com&lt;/code&gt; but your sitemap is submitted from &lt;code&gt;www.tedagentic.com&lt;/code&gt;, Google sees an inconsistency. On an established domain with strong trust signals, it might work through it. On a brand-new domain, the mismatch appeared to stop Google from trusting or prioritising the sitemap URLs entirely.&lt;/p&gt;

&lt;p&gt;It's not a warning, not a soft signal. The combination of canonical inconsistency, weak new-domain trust, and sitemap ambiguity silently stalled the entire submission.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Submit the sitemap at the exact URL the site serves on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sitemaps&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;siteUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sc-domain:tedagentic.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;feedpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://tedagentic.com/sitemap.xml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GSC downloaded it within minutes. 47 URLs detected. Posts will start getting crawled within days.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to verify on any new site
&lt;/h2&gt;

&lt;p&gt;Before assuming a crawl delay is just Google being slow, check three things in GSC:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sitemap URL matches your canonical domain exactly&lt;/strong&gt; — www vs non-www, http vs https, trailing slash vs none all count&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Last downloaded date is recent&lt;/strong&gt; — if Google downloaded your sitemap once on launch day and never again, something is wrong&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexed count is not permanently zero&lt;/strong&gt; — &lt;code&gt;submitted: 37, indexed: 0&lt;/code&gt; after a week is a hard signal, not a waiting game&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A technically clean site should show impressions within a week. If it doesn't, the sitemap submission is the first place to look.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>googlesearch</category>
      <category>astro</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Posting to X From the Server — API Setup, the 402 Wall, and the First Agentic Tweet</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 10 Jun 2026 03:46:03 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/posting-to-x-from-the-server-api-setup-the-402-wall-and-the-first-agentic-tweet-c2g</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/posting-to-x-from-the-server-api-setup-the-402-wall-and-the-first-agentic-tweet-c2g</guid>
      <description>&lt;p&gt;The goal was simple: post to X from the server. No browser, no copy-paste, no manual step. The &lt;a href="https://dev.to/posts/how-to-set-up-local-ai-agent"&gt;agent&lt;/a&gt; fires the tweet when new content goes live.&lt;/p&gt;

&lt;p&gt;Here's what actually happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: X Developer Account
&lt;/h2&gt;

&lt;p&gt;Apply at &lt;a href="https://developer.x.com" rel="noopener noreferrer"&gt;developer.x.com&lt;/a&gt;. Free to create. They ask you to describe your use cases — keep it specific and honest. Mine:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Personal automation on a self-hosted server. Posting content to @tedagentic when new posts go live. Reading my own timeline to avoid duplicates. Monitoring mentions. Single-user, no data storage beyond my own account activity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Approved same day.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: App Permissions
&lt;/h2&gt;

&lt;p&gt;This is where most people get stuck. The default app permission is &lt;strong&gt;Read only&lt;/strong&gt;. You need to change it before generating tokens — tokens generated under Read-only stay Read-only even if you change permissions later.&lt;/p&gt;

&lt;p&gt;Go to &lt;strong&gt;App Settings → User authentication settings&lt;/strong&gt; and set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App permissions:&lt;/strong&gt; Read and Write&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type of App:&lt;/strong&gt; Automated App or Bot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Callback URI:&lt;/strong&gt; &lt;code&gt;https://yourdomain.com/&lt;/code&gt; (required field, won't be used)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Website URL:&lt;/strong&gt; &lt;code&gt;https://yourdomain.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Save, then go to &lt;strong&gt;Keys and Tokens → Regenerate&lt;/strong&gt; the Access Token and Access Token Secret. The regenerated tokens inherit the new Write permission.&lt;/p&gt;

&lt;p&gt;Four keys total:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Consumer Key (API Key)&lt;/li&gt;
&lt;li&gt;Consumer Secret (API Secret)&lt;/li&gt;
&lt;li&gt;Access Token&lt;/li&gt;
&lt;li&gt;Access Token Secret&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store them in &lt;code&gt;~/.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;X_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_consumer_key
&lt;span class="nv"&gt;X_API_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_consumer_secret
&lt;span class="nv"&gt;X_ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_access_token
&lt;span class="nv"&gt;X_ACCESS_TOKEN_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_access_token_secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: The 402 Wall
&lt;/h2&gt;

&lt;p&gt;First test post returned a 402 — Payment Required.&lt;/p&gt;

&lt;p&gt;The X API free tier is read-only now. To write (post tweets), you need paid access. The old "1,500 tweets/month free" tier is gone. What X offers instead is &lt;strong&gt;pay-per-use credits&lt;/strong&gt; — no monthly commitment, load whatever you need. $7 was enough to get started.&lt;/p&gt;

&lt;p&gt;Go to the developer portal billing section, add credits, and the write endpoints unlock immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: The Posting Script
&lt;/h2&gt;

&lt;p&gt;Install the library. The script lives in &lt;code&gt;~/utils/&lt;/code&gt; so create a &lt;code&gt;package.json&lt;/code&gt; there if one doesn't exist, then install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/utils &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm init &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install &lt;/span&gt;twitter-api-v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the script at &lt;code&gt;~/utils/tweet.cjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TwitterApi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;twitter-api-v2&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;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadEnv&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;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/home/aiserver/.env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&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="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;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&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;k&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&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="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;loadEnv&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;client&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;TwitterApi&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;appKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;X_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;appSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;X_API_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;X_ACCESS_TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;accessSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;X_ACCESS_TOKEN_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&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="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;text&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;chunks&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="k"&gt;await &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;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;tweet&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tweet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Posted:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tweet&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;id&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;URL: https://x.com/tedagentic/status/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tweet&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;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&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="nx"&gt;err&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.cjs&lt;/code&gt; extension matters here — the server's root &lt;code&gt;package.json&lt;/code&gt; has &lt;code&gt;"type": "module"&lt;/code&gt; which breaks &lt;code&gt;require()&lt;/code&gt;. Naming it &lt;code&gt;.cjs&lt;/code&gt; forces CommonJS mode without touching any config.&lt;/p&gt;

&lt;p&gt;Usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node ~/utils/tweet.cjs &lt;span class="s2"&gt;"your tweet text"&lt;/span&gt;
&lt;span class="c"&gt;# or pipe in&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"tweet text"&lt;/span&gt; | node ~/utils/tweet.cjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5: First Tweet From the Server
&lt;/h2&gt;

&lt;p&gt;Test post went out. Then the real one — a blog announcement, posted directly from the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node ~/utils/tweet.cjs &lt;span class="s2"&gt;"your tweet text here"&lt;/span&gt;
&lt;span class="c"&gt;# Posted: 2054142254147604629&lt;/span&gt;
&lt;span class="c"&gt;# URL: https://x.com/tedagentic/status/2054142254147604629&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No browser. No copy-paste. The server called the API and the tweet appeared.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tweet.cjs&lt;/code&gt; is a standalone CLI right now. The next step is wiring it into OpenClaw as a skill — so the agent can post to X directly from a Telegram message. Tell the bot "post this," it calls the script, confirms the URL back to Telegram.&lt;/p&gt;

&lt;p&gt;That's the agentic layer: not automation that runs on a schedule, but a tool the agent can invoke on demand. The script is the foundation. The skill is what makes it part of the workflow.&lt;/p&gt;

&lt;p&gt;That integration is the next build.&lt;/p&gt;

</description>
      <category>twitter</category>
      <category>api</category>
      <category>node</category>
      <category>automation</category>
    </item>
    <item>
      <title>Core Web Vitals on a New Astro Blog: What the Numbers Actually Mean</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 10 Jun 2026 03:45:25 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/core-web-vitals-on-a-new-astro-blog-what-the-numbers-actually-mean-4ebk</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/core-web-vitals-on-a-new-astro-blog-what-the-numbers-actually-mean-4ebk</guid>
      <description>&lt;p&gt;I added this blog to the PageSpeed monitoring pipeline last week. The script hits Google's PageSpeed Insights API, parses Core Web Vitals, and fires the results to Telegram.&lt;/p&gt;

&lt;p&gt;The report came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🤖 tedagentic  99/100
  LCP 🟢 1.7s   CLS 🟢 0   TBT 🟢 0ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 99 on mobile for a site that's three weeks old and hasn't been manually optimised for performance. No image compression tuning, no font preloading, no custom cache headers. Just the default Astro build pushed to Vercel.&lt;/p&gt;

&lt;p&gt;That number isn't luck. It's the direct consequence of how Astro renders pages. The rest of this post is the breakdown — what each metric is, why this site scores the way it does, and what's missing from the picture.&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%2Fnmzogoxl1trleu4lnxg1.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%2Fnmzogoxl1trleu4lnxg1.jpg" alt="PageSpeed Insights showing 100/100 Performance, Accessibility, and Best Practices on tedagentic.com" width="499" height="1080"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Core Web Vitals are
&lt;/h2&gt;

&lt;p&gt;Google uses three field metrics as ranking signals under the Core Web Vitals programme:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LCP&lt;/strong&gt; — Largest Contentful Paint. How long until the largest visible element is rendered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INP&lt;/strong&gt; — Interaction to Next Paint. How long the page takes to respond to a user input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLS&lt;/strong&gt; — Cumulative Layout Shift. How much the page layout moves unexpectedly during load.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are &lt;em&gt;field&lt;/em&gt; metrics — measured from real users in Chrome, aggregated in the Chrome User Experience Report (CrUX). PageSpeed Insights shows both field data (when it exists) and lab data from a simulated Lighthouse audit. For ranking purposes, Google uses field data.&lt;/p&gt;

&lt;p&gt;The thresholds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Good&lt;/th&gt;
&lt;th&gt;Needs Improvement&lt;/th&gt;
&lt;th&gt;Poor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LCP&lt;/td&gt;
&lt;td&gt;&amp;lt; 2.5s&lt;/td&gt;
&lt;td&gt;2.5s – 4.0s&lt;/td&gt;
&lt;td&gt;&amp;gt; 4.0s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INP&lt;/td&gt;
&lt;td&gt;&amp;lt; 200ms&lt;/td&gt;
&lt;td&gt;200ms – 500ms&lt;/td&gt;
&lt;td&gt;&amp;gt; 500ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLS&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.1&lt;/td&gt;
&lt;td&gt;0.1 – 0.25&lt;/td&gt;
&lt;td&gt;&amp;gt; 0.25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;FCP (First Contentful Paint) and TBT (Total Blocking Time) are lab-only diagnostics. They don't feed directly into rankings, but they're useful proxies — TBT in particular is a strong predictor of INP.&lt;/p&gt;

&lt;h2&gt;
  
  
  LCP: 1.7s
&lt;/h2&gt;

&lt;p&gt;The largest contentful element on most posts here is the post title — an &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; in plain HTML. No hero image, no above-the-fold video, no JavaScript-rendered component.&lt;/p&gt;

&lt;p&gt;When Astro builds a page, it produces a static HTML file. The browser receives a complete document on the first request. The title is in the HTML. There's no render cycle to wait for.&lt;/p&gt;

&lt;p&gt;On a CSR React site, the sequence looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;browser request
  → server returns HTML shell (empty &amp;lt;div id="root"&amp;gt;)
  → browser downloads JS bundle
  → browser parses bundle
  → React hydrates, renders content
  → LCP element becomes visible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JS bundle download and parse adds latency before anything is visible. On a fast connection with a small bundle it might be 400–800ms. On mobile, on a slow network, with a bundle that's grown over time, it can be several seconds. That's before any data fetching.&lt;/p&gt;

&lt;p&gt;On Astro, the sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;browser request
  → server returns complete HTML
  → browser paints immediately
  → LCP recorded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No intermediate steps. The browser gets the finished document and renders it. 1.7s on mobile is almost entirely network latency and time-to-first-byte from Vercel's CDN edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLS: 0
&lt;/h2&gt;

&lt;p&gt;Cumulative Layout Shift measures visual instability — elements moving around after initial paint. The classic causes: images without dimensions, late-loading fonts that swap, ads injecting above existing content, JavaScript inserting elements above the fold.&lt;/p&gt;

&lt;p&gt;This site has no images above the fold on most pages. The fonts (Inter and JetBrains Mono) are self-hosted and loaded via &lt;code&gt;@font-face&lt;/code&gt; — they're included in the Astro build and served from the same origin. There's no web font swap from a third-party CDN, no flash of unstyled text from a late-loading Google Fonts request.&lt;/p&gt;

&lt;p&gt;There's also no JavaScript inserting DOM elements after paint. No React hydration reshuffling the layout, no analytics widget pushing content down, no cookie banner appearing above the fold.&lt;/p&gt;

&lt;p&gt;CLS of 0 is the natural result of serving a page that looks the same the moment the HTML lands as it does after everything has loaded. When nothing changes after initial paint, nothing shifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  TBT: 0ms
&lt;/h2&gt;

&lt;p&gt;Total Blocking Time measures time spent blocking the main thread during page load — specifically, the sum of time any task takes longer than 50ms. It's a lab proxy for how responsive the page feels.&lt;/p&gt;

&lt;p&gt;The main thread gets blocked by long JavaScript tasks. Parsing a large bundle, executing framework initialisation code, running hydration — these all create tasks that lock the main thread and prevent it from responding to user inputs.&lt;/p&gt;

&lt;p&gt;This site ships zero JavaScript to the browser by default. There's no bundle to parse. The inline script in the layout is eleven lines for scroll-based header styles and a hamburger toggle — nowhere near 50ms to execute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────┐
│                 MAIN THREAD ACTIVITY                 │
├─────────────────────────────────────────────────────┤
│                                                      │
│  CSR React blog (typical)                           │
│  ──────────────────────────────────────────         │
│  [parse HTML] [████████████ parse bundle ████] ...  │
│               └── blocking task &amp;gt; 50ms              │
│               TBT: 200–600ms                        │
│                                                      │
│  Astro static (this site)                           │
│  ──────────────────────────────────────             │
│  [parse HTML] [inline script, 11 lines]             │
│                └── &amp;lt; 50ms, not blocking             │
│               TBT: 0ms                              │
│                                                      │
└─────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0ms TBT means the main thread is free from the moment the HTML is parsed. Any user input — a tap, a scroll, a click — is handled immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  INP: n/a
&lt;/h2&gt;

&lt;p&gt;This is the one that needs explaining.&lt;/p&gt;

&lt;p&gt;INP shows as &lt;code&gt;n/a&lt;/code&gt; in PageSpeed Insights. That doesn't mean it's zero — it means there's no data. INP is a field metric. It requires real users to interact with the page in Chrome, and that interaction data to be collected in CrUX.&lt;/p&gt;

&lt;p&gt;This domain is three weeks old. There isn't enough real-user interaction data for Google to report an INP score. Once the site accumulates CrUX data — typically after a few months of real traffic — INP will appear.&lt;/p&gt;

&lt;p&gt;TBT is the lab proxy in the meantime. 0ms TBT is a strong signal that INP will be good when it does appear — the two metrics are closely correlated. A page with no long main-thread tasks during load is a page that responds instantly to inputs.&lt;/p&gt;

&lt;p&gt;The distinction matters for how you interpret PSI reports on new sites. A missing INP isn't a gap in performance — it's a gap in data collection. The absence of field data is normal for any site with limited traffic. Lab scores (TBT, FCP, LCP in lab mode) are still meaningful and actionable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monitoring pipeline
&lt;/h2&gt;

&lt;p&gt;The script that produced this report runs weekly via cron, checks all four sites, and fires to Telegram via OpenClaw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SITES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tedagentic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://tedagentic.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🤖&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;# other sites omitted
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each site gets audited against Google's PageSpeed Insights API, scores are stored to a JSON history file for trend tracking, and the report is formatted and sent to Telegram.&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%2F7g30u9q8qgsgab177fam.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%2F7g30u9q8qgsgab177fam.jpg" alt="Telegram message showing tedagentic 98/100 with LCP, CLS, and TBT scores" width="499" height="1080"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│                  WEEKLY PIPELINE                      │
├──────────────────────────────────────────────────────┤
│                                                       │
│  cron (Sunday 10:30am)                               │
│       │                                              │
│       ▼                                              │
│  speed_test.py                                       │
│       │                                              │
│       └─→ PSI API → tedagentic.com → score          │
│                           │                          │
│                           ▼                          │
│                    history.json (trend delta)        │
│                           │                          │
│                           ▼                          │
│                    OpenClaw → Telegram               │
│                                                      │
└──────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trend delta is the part that matters over time. A score of 99 this week is useful context. A score that drops from 99 to 84 after a deploy is a signal to investigate immediately. The history file tracks the last score per site per strategy — the Telegram report shows the delta alongside the current number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the stack choice decides for you
&lt;/h2&gt;

&lt;p&gt;The 99/100 on this blog required no performance work. No audit pass, no image optimisation sprint, no lazy loading configuration.&lt;/p&gt;

&lt;p&gt;That's not because Astro is magic. It's because the framework's defaults eliminate the performance failure modes that require active remediation on other stacks.&lt;/p&gt;

&lt;p&gt;Zero JavaScript by default means no bundle to block the main thread. Static HTML by default means no render cycle to delay LCP. Self-hosted assets by default means no third-party round trips to create layout shifts.&lt;/p&gt;

&lt;p&gt;On a React CSR blog the starting point is roughly the opposite: a JS bundle is required, it blocks the main thread during parse, and LCP is gated behind hydration. Getting from that baseline to a 90+ score requires deliberate work — code splitting, lazy hydration, image optimisation, font strategy. It's achievable. It's also ongoing. Every new dependency added, every new component rendered above the fold, requires re-evaluation.&lt;/p&gt;

&lt;p&gt;The Astro baseline is 99. The work is keeping it there, which mostly means not introducing the things that break it: large above-the-fold images without dimensions, third-party scripts in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, client-side components that hydrate on load rather than on interaction.&lt;/p&gt;

&lt;p&gt;The score you see in PSI is a consequence of the rendering decision you made when you chose the stack. Fix the stack, and most of the performance work is already done.&lt;/p&gt;

</description>
      <category>webperf</category>
      <category>astro</category>
      <category>seo</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
