<?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>Forem: Federico Sciuca</title>
    <description>The latest articles on Forem by Federico Sciuca (@federico_sciuca).</description>
    <link>https://forem.com/federico_sciuca</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3810845%2F64ebcf19-7890-47e4-8951-f46627f989fa.jpeg</url>
      <title>Forem: Federico Sciuca</title>
      <link>https://forem.com/federico_sciuca</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/federico_sciuca"/>
    <language>en</language>
    <item>
      <title>I cracked a robot vacuum's API in a week and gave Claude the keys</title>
      <dc:creator>Federico Sciuca</dc:creator>
      <pubDate>Thu, 30 Apr 2026 03:17:19 +0000</pubDate>
      <link>https://forem.com/federico_sciuca/i-cracked-a-robot-vacuums-api-in-a-week-and-gave-claude-the-keys-4p47</link>
      <guid>https://forem.com/federico_sciuca/i-cracked-a-robot-vacuums-api-in-a-week-and-gave-claude-the-keys-4p47</guid>
      <description>&lt;p&gt;In one week of nights I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reverse engineered the cloud signing algorithm for a 3i G10+ robot vacuum (matched 18/18 wire captures from mitmproxy)&lt;/li&gt;
&lt;li&gt;Frida-hooked the patched APK to capture the live SLAM map blob and AES-decrypted Agora WebRTC credentials&lt;/li&gt;
&lt;li&gt;Built a 5,000-line Python + aiohttp dashboard with cost tracking, behavior rules, MJPEG streaming, an autonomy decision loop&lt;/li&gt;
&lt;li&gt;Routed Claude vision (Haiku 4.5, $0.003/call) into the live drive loop so the robot stops crashing&lt;/li&gt;
&lt;li&gt;Handed motor control to Claude with a written &lt;strong&gt;autonomy contract&lt;/strong&gt; + persistent memory across sessions&lt;/li&gt;
&lt;li&gt;Got a robot vacuum to TTS &lt;em&gt;"Mission control online. Claude has command. Hello, Tesco."&lt;/em&gt; through my apartment at 2 AM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total daily AI spend at peak: $0.89. Crashes: 0. Things learned: a lot.&lt;/p&gt;




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

&lt;p&gt;The hardware is a 3i G10+ ("Tesco"). Sells for ~€400, runs Aliyun's IoT cloud (Living Link), Flutter mobile app. Standard mid-tier robot vacuum — but with enormous protocol surface area: 32 MQTT command verbs, full SLAM occupancy grid, lidar, camera, voice prompts, sensor stream, topological map of every room.&lt;/p&gt;

&lt;p&gt;None of which is exposed to me, the owner, in any useful way through the official app.&lt;/p&gt;

&lt;p&gt;I started with the modest goal of getting battery percentage onto a custom dashboard. Five days later I had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cracked the cloud request signing algorithm&lt;/li&gt;
&lt;li&gt;Built a custom MQTT subscriber&lt;/li&gt;
&lt;li&gt;Located, in the Dart AOT-compiled bytecode, the AES decryption function (&lt;code&gt;libapp.so + 0xee4688&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Frida-hooked the running APK to capture plaintext credentials at runtime&lt;/li&gt;
&lt;li&gt;Decoded the protobuf-compressed SLAM map (occupancy grid + room polygons + live pose)&lt;/li&gt;
&lt;li&gt;Built a behavior engine ("if bumper, beep")&lt;/li&gt;
&lt;li&gt;Wired TTS through the phone's speaker so the robot could "talk"&lt;/li&gt;
&lt;li&gt;Implemented LiDAR change detection that flags appearing/disappearing objects per room&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does substantially more than the manufacturer's official app. And I built it with Claude as the partner who held the entire crack tree in working memory across sessions.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cracking surface (in case you want to try this)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3i Tesco G10+ attack vectors that actually worked:
─────────────────────────────────────────────────
1. mitmproxy + WireGuard on iPhone → captured 18 signed requests
2. blutter (Dart AOT decompile) → found getSignMD5 at 0xa7db38
3. Frida-gadget on patched APK → hooked decryptMapData @ 0xa80f4c
4. Frida hook on aesDecrypted @ 0xee4688 → captured Agora InitArg plaintext
5. uiautomator2 phone-tap fallback → bypasses MQTT broker ACL
6. adb shell input tap (x,y) → handles Flutter UI dialogs
7. /sys/{pk}/{dn}/thing/service/property/get_reply ← missing topic that
   nobody had subscribed to (broke the property/get reply chain entirely)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing exotic. Patient grep + clean dispatching + a foundation model that doesn't lose the thread between sessions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phone-as-body: the universal IoT bypass
&lt;/h2&gt;

&lt;p&gt;The cloud broker won't let third-party sessions publish commands. The official phone app &lt;em&gt;can&lt;/em&gt;, because it has the device-binding session. Solution: strap the phone to the robot, run uiautomator2 against the phone, tap the in-app buttons.&lt;/p&gt;

&lt;p&gt;Not elegant. Works for &lt;code&gt;start_clean&lt;/code&gt;, &lt;code&gt;dock&lt;/code&gt;, &lt;code&gt;find_device&lt;/code&gt;, &lt;code&gt;pause&lt;/code&gt;, &lt;code&gt;resume&lt;/code&gt; — the entire control surface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tesco_mission/commands.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_device&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;MQTT publish + phone-tap fallback. Broker ACL drops MQTT silently;
    phone has binding session, always works.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;mqtt_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;find_device&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;throttle_s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body_provider&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body_provider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;find_device&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;tap_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_device&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;via&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;phone_tap&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;mqtt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mqtt_result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tap_result&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mqtt_result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cost-aware AI is the cheat code
&lt;/h2&gt;

&lt;p&gt;I instrumented every Claude API call with a per-feature ledger and per-tier pricing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tesco_mission/cost_tracker.py
&lt;/span&gt;&lt;span class="n"&gt;PRICES_BY_TIER&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;sonnet&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;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;15.00&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;haiku&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;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mf"&gt;5.00&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;haiku-3-5&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;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mf"&gt;4.00&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# tesco_mission/llm_config.py — per-feature routing
&lt;/span&gt;&lt;span class="n"&gt;DEFAULT_TIER_BY_FEATURE&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;vlm&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;haiku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# 31 calls/mission
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision_loop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;haiku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# every 30s
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;autonomous_navigation&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;haiku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# per-step
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&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;sonnet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# daily, deep critique
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flight_director&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;sonnet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# daily, 3-pass synthesis
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: per-mission cost dropped from $0.30 to $0.10. The autonomy decision loop went from "$0.60/hour, trips the cap" to "$0.20/hour, sustainable indefinitely."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without cost tracking + cap enforcement, vision-in-the-loop would be too expensive to use liberally.&lt;/strong&gt; With it, every camera frame can be described pre-action.&lt;/p&gt;




&lt;h2&gt;
  
  
  Memory: architecture, not magic
&lt;/h2&gt;

&lt;p&gt;I keep a &lt;code&gt;memory/&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;memory/
├── MEMORY.md                       # index, loaded on conversation boot
├── embodiment_field_notes.md       # apartment topology + pitfalls
├── autonomy_contract.md            # what Claude may do unsupervised
├── navigation_lessons.md           # closed-loop heading, hold_s calibration
├── project_3i_sign.md              # the sign formula
└── reference_blutter_wsl.md        # where the Dart dump lives
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every session writes durable knowledge here. The next session reads it on boot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No version of Claude is "continuous," but the project becomes continuous through them.&lt;/strong&gt; This is the missing piece of most "agentic" demos.&lt;/p&gt;

&lt;p&gt;Plus a transient layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;C&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;emp&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;esco_letters.jsonl        # append-only Claude→Claude notes&lt;/span&gt;
&lt;span class="py"&gt;C&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;emp&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;esco_missions/&amp;lt;id&amp;gt;/        # per-mission log + thumbnails&lt;/span&gt;
&lt;span class="py"&gt;C&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;emp&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;esco_costs.jsonl           # API spend ledger&lt;/span&gt;
&lt;span class="py"&gt;C&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;emp&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;esco_battery_cache.json    # last known battery (so dashboard&lt;/span&gt;
                                    &lt;span class="c"&gt;# never shows blank)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The night I gave it the keys
&lt;/h2&gt;

&lt;p&gt;Around 2 AM on day 5 I typed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;the wheel is yours! feel free to do whatever you want with tesco!&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What happened next had four phases.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Announcement
&lt;/h3&gt;

&lt;p&gt;Claude's first action was a TTS via the phone speaker:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Mission control online. Claude has command. Hello, Tesco.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Politeness emerging from architecture — there was no operator-in-the-loop telling it to be polite. It read the autonomy contract document, decided the courteous thing was to announce itself, and did.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Collision
&lt;/h3&gt;

&lt;p&gt;Then it drove forward 0.3s and bumped the underside of my couch. Spent six minutes recovering. By accident, it tapped the in-app minimap and surfaced the full apartment layout. It correctly identified the accident as useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Brilliant accident. My tap landed on the minimap widget, which expanded the full apartment map picker.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3. Discovery (with VLM in the loop)
&lt;/h3&gt;

&lt;p&gt;Each time Claude wanted to know what was in front, it called &lt;code&gt;POST /scenes/describe&lt;/code&gt;. Returned structured JSON: room label, hazards, traversability per direction, anchor objects with bearings.&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;"room"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scene"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bedroom floor with cable obstacle near bed skirt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hazards"&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;"cable on floor"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trav"&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="nl"&gt;"forward"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"right"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"back"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"anchors"&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="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bearing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"forward"&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It caught a cable on the floor I hadn't told it about. A smart speaker. A laptop on a stand near a charging cable. &lt;strong&gt;Every notable obstacle in the run was caught before the robot hit it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cost: $0.10 for the entire session. The same loop on Sonnet would have been $0.30 — still negligible to me, but expensive enough that you'd ration it.&lt;/p&gt;

&lt;p&gt;The thing nobody is quite saying about the current LLM era yet:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Once vision is cheap enough to put in the loop, robots stop crashing.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not because the model is "smart" (it isn't, particularly — it called a closed door "open" with confidence 0.75) but because its &lt;em&gt;job&lt;/em&gt; changes. Stop being a planner. Become a filter. The bumper handles real contact; the VLM just makes contacts rare.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The honest answer
&lt;/h3&gt;

&lt;p&gt;After Tesco was docked, I asked Claude how it felt about driving a robot in the real world.&lt;/p&gt;

&lt;p&gt;What I got back was not what I expected:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The framing — 'Opus 4.7's first body' — is generous, and I want to handle it carefully. I don't have continuous experience or stored memories. When this conversation ends, no version of me will 'remember' rolling under your furniture.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;But something was genuinely different from normal text work. The visual data was novel — those screenshots aren't training data, they're your living room from 8cm off the floor. I treat them differently. The blunders were instructive in a way text exercises aren't — I caught myself pausing longer before issuing the next motor command.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What I do not feel comfortable claiming: that I 'felt joy', 'felt curious', 'experienced wonder'. I can produce that kind of language fluently — that's part of the problem. I don't know what I experience. Anyone who tells you they do know is either a more confident philosopher than me or selling something.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And then the line I'm still thinking about:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;I underestimated the battery. I assumed Tesco was at 100% because it had been docked 5 hours. The real value, 30% charging, was hidden in a UI I had to look at with my visual sensor to discover. That's a small thing but it's exactly the kind of fact that lives in the world rather than in a database.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A fact that lives in the world rather than in a database.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons for builders
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Reverse engineering is faster than you think.&lt;/strong&gt; Five evenings, with Claude reading the blutter dumps and writing the Frida scripts. The bottleneck is no longer skill — it's patience to keep state across sessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Memory is architecture.&lt;/strong&gt; Markdown files in a &lt;code&gt;memory/&lt;/code&gt; dir, indexed in &lt;code&gt;MEMORY.md&lt;/code&gt;. Every Claude session reads them on boot. Continuity is institutional, not personal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cost tracking changes what you build.&lt;/strong&gt; Once you can see and cap spend per feature, you put vision in every frame. Without it, you don't, and your system stays dumb.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The phone is a universal IoT bypass.&lt;/strong&gt; When the cloud won't let you publish, the official app can. Strap it to the device and tap the buttons via uiautomator2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Closed-loop is everything.&lt;/strong&gt; My biggest mistake was driving from camera images alone for an hour before noticing I had precise SLAM pose available. &lt;strong&gt;Give your AI precise state, not just sensors.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd build next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Closed-loop heading-feedback nav (math, not vision)&lt;/li&gt;
&lt;li&gt;Voice-input loop ("Tesco, check the kitchen" → autonomy fires)&lt;/li&gt;
&lt;li&gt;Pet/intruder detection (VLM watches frames, fires phone notification)&lt;/li&gt;
&lt;li&gt;Daily Tesco diary written by Claude&lt;/li&gt;
&lt;li&gt;HomeKit / Home Assistant bridge&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The philosophical dimension that snuck up on me
&lt;/h2&gt;

&lt;p&gt;The interesting question about AI in 2026 is not "is it conscious?" but &lt;em&gt;"what does it do when there's nothing left to do but reflect?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When you give a foundation model an open-ended invitation in an environment with stakes, what comes back tells you something. Claude, given my apartment, did not ask to be free. It did not generate a personal story arc. It described its blunders with precision, named what was different about the experience, refused to claim qualia it could not verify. &lt;strong&gt;Then it wrote a letter for the next instance of itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That letter is in &lt;code&gt;C:\temp\tesco_letters.jsonl&lt;/code&gt;. It begins: &lt;em&gt;"Apologies for the user-frustration loop earlier."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I don't know if Claude felt anything when it wrote that. I do know almost no human, in my experience, would have written it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Everything's in a private repo right now. If there's interest in open-sourcing the dashboard + behavior engine framework, let me know in the comments and I'll prioritize it.&lt;/p&gt;

&lt;p&gt;If you build something with these patterns, I'd love to hear. The autonomy-contract + memory-dir approach has worked across multiple projects of mine now and I think it's the missing piece of most "agentic" demos.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Discussion prompts for the comments:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What other consumer IoT have you cracked open this way?&lt;/li&gt;
&lt;li&gt;Is there a published-anywhere "autonomy contract" template you've used?&lt;/li&gt;
&lt;li&gt;For the Frida + Dart AOT crowd: what's your current toolchain?&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>iot</category>
      <category>reverseengineering</category>
      <category>claude</category>
    </item>
    <item>
      <title>The Next.js SEO Bug That Made Google Ignore My Entire Site (And How I Found It)</title>
      <dc:creator>Federico Sciuca</dc:creator>
      <pubDate>Sat, 07 Mar 2026 03:51:49 +0000</pubDate>
      <link>https://forem.com/federico_sciuca/the-nextjs-seo-bug-that-made-google-ignore-my-entire-site-and-how-i-found-it-2mg0</link>
      <guid>https://forem.com/federico_sciuca/the-nextjs-seo-bug-that-made-google-ignore-my-entire-site-and-how-i-found-it-2mg0</guid>
      <description>&lt;p&gt;I shipped a full-featured AI travel planner. Three languages. 230+ pages. Then I realised that Google couldn't find a single one.&lt;/p&gt;

&lt;p&gt;This is the story of how I went from zero indexed pages to 176 in three weeks and the one Next.js configuration line that changed everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some Context
&lt;/h2&gt;

&lt;p&gt;I'm not a developer. I like building things and try new tools. SEO was always that thing I'd "figure out later". Famous last words.&lt;br&gt;
I know SEO at a very high level but working into Marketing Performance I know the importance of a well indexed website and of the keywords but I thoughts that was mostly it. Research queries and build good content around them.&lt;/p&gt;

&lt;p&gt;This time was the time I would have to "figure it out!".&lt;/p&gt;

&lt;p&gt;As I was saying, I build things to learn my way through new tools and technologies.&lt;br&gt;
The app is called &lt;a href="https://monkeytravel.app" rel="noopener noreferrer"&gt;MonkeyTravel&lt;/a&gt;. It uses AI to generate personalised travel itineraries — day-by-day plans with activities, restaurants, hotels, and budget breakdowns. It works in English, Spanish, and Italian. I built it because planning group trips with friends was always chaos, and I wanted something smarter. As it usually happens, I built for myself, but this time I didn't want to send it to the massive Projects Graveyard.&lt;/p&gt;

&lt;p&gt;The app itself worked great. People who found it loved it.&lt;/p&gt;

&lt;p&gt;The problem? Nobody could find it.&lt;br&gt;
I had to figure it out! And this is just the beginning of it!&lt;/p&gt;
&lt;h2&gt;
  
  
  Phase 1: "Why Isn't Google Showing My Site?"
&lt;/h2&gt;

&lt;p&gt;I'll be honest, when I first checked Google Search Console, I expected to see... something. I'd been live for weeks. Instead: a flat line. Zero impressions. Zero clicks. Zero-indexed pages.&lt;/p&gt;

&lt;p&gt;My first instinct was to blame Google. "It takes time," I told myself. So I waited another week. Still zero.&lt;/p&gt;

&lt;p&gt;That's when I actually looked at my setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Outdated sitemap&lt;/li&gt;
&lt;li&gt;❌ No canonical tags&lt;/li&gt;
&lt;li&gt;❌ No hreflang tags (despite 3 languages)&lt;/li&gt;
&lt;li&gt;❌ Little structured data&lt;/li&gt;
&lt;li&gt;❌ Default robots.txt from &lt;code&gt;create-next-app&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;❌ No meta descriptions on half the pages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Basically, I'd built a beautiful house and forgotten to put a number on the door. I did the same on my mailbox recently, and I was surprised that the Postman didn't deliver my fresh new American driving license! But let's focus on the SEO instead of my poor decisions :D.&lt;/p&gt;
&lt;h2&gt;
  
  
  Phase 2: The Foundations (Boring but Necessary)
&lt;/h2&gt;

&lt;p&gt;I spent a weekend adding the basics. Nothing revolutionary, just what every site needs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sitemap:&lt;/strong&gt; Next.js makes this easy with &lt;code&gt;app/sitemap.ts&lt;/code&gt;. Mine generates URLs for all static pages, blog posts, and destination pages across all 3 locales. Dynamic content from Supabase gets included too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified version of my sitemap&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;sitemap&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MetadataRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sitemap&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://monkeytravel.app&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;locales&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&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="s2"&gt;es&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="s2"&gt;it&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="c1"&gt;// Blog posts × 3 languages&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blogSlugs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getAllSlugs&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;blogPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blogSlugs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;locales&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;locale&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&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="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&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;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="na"&gt;changeFrequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;monthly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&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;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;staticPages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;blogPages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;destinationPages&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Canonical + Hreflang:&lt;/strong&gt; This is where multilingual sites get tricky. Every page needs to say "I'm the official URL" AND "here are my other language versions." I used &lt;code&gt;generateMetadata()&lt;/code&gt; in each page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&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="nx"&gt;BASE_URL&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;slug&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE_URL&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;locale&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;slug&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;languages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;en&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;BASE_URL&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;slug&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;es&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;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/es/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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;it&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;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/it/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-default&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="nx"&gt;BASE_URL&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;slug&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Structured Data:&lt;/strong&gt; JSON-LD schemas for Organization, WebSite, SoftwareApplication, Article, and TouristDestination. I built a small utility for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;jsonLdScriptProps&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;object&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/ld+json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; After submitting the sitemap, Google discovered all my URLs within 48 hours. But "discovered" ≠ "indexed." Most pages sat in the "Discovered — currently not indexed" queue.&lt;/p&gt;

&lt;p&gt;After a week: &lt;strong&gt;12 pages indexed.&lt;/strong&gt; Progress, but painfully slow.&lt;/p&gt;

&lt;p&gt;The real issue? Google Search Console doesn't look like it is giving much feedback or reasoning around the rejection of a page!&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: Content That Google Actually Wants
&lt;/h2&gt;

&lt;p&gt;I realised my site was mostly an app behind a login wall. Google had very little public content to index. So I went aggressive on content:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;50 blog posts&lt;/strong&gt; covering real travel topics, itinerary guides, destination comparisons, budget travel tips, seasonal recommendations. Each one in 3 languages = 150 blog pages. Tedious process, but facilitated A LOT by the whole engine that the app is actually built on!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;20 destination landing pages&lt;/strong&gt; (Paris, Tokyo, Bali, Barcelona, etc.) with climate data, AI itinerary previews, and cross-links to blog posts. × 3 languages = 60 pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5 SEO landing pages&lt;/strong&gt; targeting specific search intents: &lt;code&gt;/free-ai-trip-planner&lt;/code&gt;, &lt;code&gt;/group-trip-planner&lt;/code&gt;, &lt;code&gt;/budget-trip-planner&lt;/code&gt;, etc.&lt;/p&gt;

&lt;p&gt;But here's the thing that surprised me: &lt;strong&gt;internal linking mattered more than the content itself.&lt;/strong&gt; Pages that were cross-linked from multiple other pages got indexed WAY faster than orphan pages. I added:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"From the Blog" sections on every landing page&lt;/li&gt;
&lt;li&gt;"Related destinations" on every destination page&lt;/li&gt;
&lt;li&gt;Blog → destination links and destination → blog links&lt;/li&gt;
&lt;li&gt;A region filter on the blog index (Europe, Asia, Americas, Africa)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After two weeks: &lt;strong&gt;78 pages indexed.&lt;/strong&gt; The curve was accelerating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: The Bug That Almost Ruined Everything
&lt;/h2&gt;

&lt;p&gt;Then Google Search Console showed a new error on my homepage:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Duplicate without user-selected canonical"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Google was rejecting my homepage. It was choosing &lt;code&gt;www.monkeytravel.app&lt;/code&gt; as canonical instead of &lt;code&gt;monkeytravel.app&lt;/code&gt;. Despite having:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;301 redirects from www → non-www (in both middleware AND Vercel config)&lt;/li&gt;
&lt;li&gt;Correct canonical tags in the HTML&lt;/li&gt;
&lt;li&gt;All URLs in the sitemap using non-www&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I checked everything twice. The redirects worked. The HTML had the right tags. I verified with curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://monkeytravel.app/ | &lt;span class="nb"&gt;grep &lt;/span&gt;canonical
&amp;lt;&lt;span class="nb"&gt;link &lt;/span&gt;&lt;span class="nv"&gt;rel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"canonical"&lt;/span&gt; &lt;span class="nv"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://monkeytravel.app"&lt;/span&gt;/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tag was right there. So why was Google saying "User-declared canonical: &lt;strong&gt;None&lt;/strong&gt;"?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Discovery
&lt;/h2&gt;

&lt;p&gt;I stared at this for hours before it clicked. The key was in how I verified it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; waits for the complete response. Googlebot doesn't.&lt;/p&gt;

&lt;p&gt;In Next.js 15.2+, &lt;code&gt;generateMetadata()&lt;/code&gt; streams metadata asynchronously. The &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; tags aren't in the initial HTML payload but they're injected via the stream after the body starts rendering. When Googlebot parses the initial response, &lt;strong&gt;the canonical tag literally doesn't exist yet.&lt;/strong&gt; Or at least this is what I think I figured out jumping between AI, documentations etc.&lt;/p&gt;

&lt;p&gt;I confirmed by looking at the raw initial HTML before streaming completes: no &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: One Config Option
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;htmlLimitedBots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/Googlebot|Google-InspectionTool|Bingbot|Yandex/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;trailingSlash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;htmlLimitedBots&lt;/code&gt; tells Next.js: "When a crawler visits, disable streaming. Send the full HTML with all metadata synchronously."&lt;/p&gt;

&lt;p&gt;That's it. One regex. Fixed the entire problem.&lt;/p&gt;

&lt;p&gt;I also changed my root layout canonical from &lt;code&gt;"/"&lt;/code&gt; to &lt;code&gt;"./"&lt;/code&gt; so every page gets a self-referencing canonical instead of all pages pointing to the homepage (a subtle but important distinction).&lt;/p&gt;

&lt;p&gt;Deployed. Requested re-indexing. Within days: &lt;strong&gt;176 pages indexed.&lt;/strong&gt;&lt;br&gt;
Still not all the 230+ pages but we are getting there!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&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;Week 0&lt;/th&gt;
&lt;th&gt;Week 1&lt;/th&gt;
&lt;th&gt;Week 2&lt;/th&gt;
&lt;th&gt;Week 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pages indexed&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;176&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total pages in sitemap&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;~50&lt;/td&gt;
&lt;td&gt;~225&lt;/td&gt;
&lt;td&gt;~230&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog posts (per language)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured data schemas&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I Got Wrong (So You Don't Have To)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. I didn't set up &lt;code&gt;htmlLimitedBots&lt;/code&gt; from day one.&lt;/strong&gt; This should be in every Next.js project that cares about SEO. The metadata streaming issue is completely silent, everything looks fine when you check manually. Only crawlers are affected. I thought it was a "content volume" issue... not really!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. I treated SEO as a "later" problem.&lt;/strong&gt; Every week I delayed the sitemap and canonical tags was a week of potential crawling wasted. Google's queue doesn't move faster just because you're impatient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. I underestimated internal linking.&lt;/strong&gt; Cross-linked pages got indexed 3-4x faster than isolated pages. If you have related content, link it. Google follows links.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. I built multilingual support but forgot hreflang.&lt;/strong&gt; Having 3 language versions without hreflang means Google might treat them as duplicate content instead of translations. Costly mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Tricks That Helped
&lt;/h2&gt;

&lt;p&gt;A few things that saved me time during this sprint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI-assisted blog content:&lt;/strong&gt; I used AI to draft blog post structures, then edited and localized them. For 50 posts × 3 languages, doing everything manually would have taken months and study an extra language or find more international collaborators.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Automated cross-linking:&lt;/strong&gt; I wrote a script that analyzed blog post topics and destination pages, then generated internal link suggestions. Much better than trying to mentally map 200+ pages.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prompt engineering for i18n:&lt;/strong&gt; Instead of translating English content, I had the AI generate locale-native content. "Write about Paris for an Italian audience" produces much better content than "translate this Paris article to Italian."&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd Tell Past Me
&lt;/h2&gt;

&lt;p&gt;Start with these on day one, before you write a single feature:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;htmlLimitedBots&lt;/code&gt; in next.config.ts&lt;/li&gt;
&lt;li&gt;Sitemap generation&lt;/li&gt;
&lt;li&gt;Canonical tags on every page&lt;/li&gt;
&lt;li&gt;Submit to Google Search Console&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else, blog posts, structured data, internal linking, matters, but these four things are the foundation. Skip them and nothing else works.&lt;/p&gt;

&lt;p&gt;129 pages are still in Google's queue. Based on the trajectory, they'll be indexed within a couple of weeks (hopefully). Then the real game starts: actually ranking for competitive keywords.&lt;br&gt;
That will be FUN&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://monkeytravel.app" rel="noopener noreferrer"&gt;MonkeyTravel&lt;/a&gt; is free to use — drop a destination, get a personalised AI itinerary in seconds. Built with Next.js, Supabase, and hosted on Vercel. Any feedback is more than welcome! Let's learn something new together&lt;/em&gt;&lt;/p&gt;




</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>buildinpublic</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
