<?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: Alex Shev</title>
    <description>The latest articles on DEV Community by Alex Shev (@alexshev).</description>
    <link>https://dev.to/alexshev</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%2F3812101%2F0946c1f5-f8a5-464c-bf18-10850a434ef9.png</url>
      <title>DEV Community: Alex Shev</title>
      <link>https://dev.to/alexshev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexshev"/>
    <language>en</language>
    <item>
      <title>When ‘Nearest Driver’ Is Not the Nearest Driver: What UBCab’s Routing Stack Teaches Us</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Sat, 10 Oct 2026 18:44:43 +0000</pubDate>
      <link>https://dev.to/alexshev/when-nearest-driver-is-not-the-nearest-driver-what-ubcabs-routing-stack-teaches-us-20kc</link>
      <guid>https://dev.to/alexshev/when-nearest-driver-is-not-the-nearest-driver-what-ubcabs-routing-stack-teaches-us-20kc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Media credit:&lt;/strong&gt; Google Maps Platform / UBCab via the &lt;a href="https://www.linkedin.com/posts/mongolias-ubcab-holding-navigated-unique-share-7513977558082801665-nJGL/" rel="noopener noreferrer"&gt;source LinkedIn post&lt;/a&gt;. The image is retained unchanged as sourced media; rights remain with its owner.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The dispatch mistake hidden in a simple distance calculation
&lt;/h2&gt;

&lt;p&gt;“Nearest available driver” sounds like a geometric problem. In a real city, it is usually an operations problem.&lt;/p&gt;

&lt;p&gt;The driver who is closest in straight-line distance may be separated from a rider by a river crossing, a traffic bottleneck, a one-way system, or a route that becomes impractical in winter. Assign that driver anyway and the marketplace absorbs the consequences: volatile ETAs, rejected trips, riders waiting longer than promised, and support teams explaining the gap.&lt;/p&gt;

&lt;p&gt;That is the useful lesson in a new case study from Mongolia’s UBCab. The company operates mobility, delivery, and other services in Ulaanbaatar. In a city where winter temperatures can fall to −40 °C and formal addresses are not always sufficient to identify a pickup point, a distance-only dispatch rule was never going to be enough.&lt;/p&gt;

&lt;p&gt;Google Maps Platform published the case study on &lt;strong&gt;October 8, 2026&lt;/strong&gt;. It describes how UBCab changed the core question in its dispatch engine from “which driver is physically closest?” to “which driver can actually arrive first?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Replace distance with arrival time
&lt;/h2&gt;

&lt;p&gt;UBCab’s earlier matching flow used straight-line distance to find nearby drivers. That shortcut ignored road layout and live traffic, so a driver who looked close on a map could still be a long drive away.&lt;/p&gt;

&lt;p&gt;The replacement was a travel-time model based on Google Maps Platform Mobility services and the Routes API. The dispatch engine evaluates road layout and current traffic before it assigns a trip. That gives the system a more useful input for both matching and rider communication: a defensible ETA.&lt;/p&gt;

&lt;p&gt;This is a broadly applicable architecture pattern for any delivery or marketplace product:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate candidate supply near a request.&lt;/li&gt;
&lt;li&gt;Score candidates on estimated time of arrival, not only spatial distance.&lt;/li&gt;
&lt;li&gt;Include live road conditions and routing constraints in that score.&lt;/li&gt;
&lt;li&gt;Return an ETA that the product can stand behind.&lt;/li&gt;
&lt;li&gt;Measure the downstream effects: acceptance, waiting time, cancellations, support contacts, and pricing disputes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key is not simply adding a map. It is putting routing intelligence inside the decision that determines who gets the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  A map is a base layer, not the whole location model
&lt;/h2&gt;

&lt;p&gt;The more interesting part of the UBCab story is that accurate routing still did not solve location by itself.&lt;/p&gt;

&lt;p&gt;Parts of Ulaanbaatar do not have formal or consistently used street addresses. People may describe destinations through local landmarks and inherited neighborhood language rather than an official postal address. UBCab overlaid its proprietary addressing system on top of the map base layer so drivers could identify the intended pickup point precisely.&lt;/p&gt;

&lt;p&gt;That is a strong reminder for teams building location-aware products: external map data is foundational, but local operational knowledge can be the differentiator.&lt;/p&gt;

&lt;p&gt;A robust location stack may need to combine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a commercial or open map base;&lt;/li&gt;
&lt;li&gt;travel-time and traffic data;&lt;/li&gt;
&lt;li&gt;a locally maintained place/landmark layer;&lt;/li&gt;
&lt;li&gt;aliases users and drivers actually recognize;&lt;/li&gt;
&lt;li&gt;confidence signals when an address is ambiguous; and&lt;/li&gt;
&lt;li&gt;a correction loop that turns failed pickups into better location data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is not to replace the global map. It is to create a translation layer between how a city is represented in a mapping system and how people actually navigate it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Partner note:&lt;/strong&gt; This article includes a promotional link to &lt;a href="https://seog.ai/" rel="noopener noreferrer"&gt;SEOG.ai&lt;/a&gt;. For local businesses, technical location data only becomes useful when the public business profile, customer feedback, and neighborhood-level visibility are also maintained. SEOG.ai is a local-SEO workspace for auditing Google Business Profiles, tracking local map rankings, monitoring reviews and nearby competitors, and turning those signals into a prioritized action list. It is relevant here for the layer &lt;em&gt;after&lt;/em&gt; the route is complete: making sure a customer can find, trust, and choose a local business in the first place. Review its recommendations before publishing changes; SEO visibility and rankings are not guaranteed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Keep navigation in the driver workflow
&lt;/h2&gt;

&lt;p&gt;UBCab also integrated turn-by-turn navigation into its own driver app, rather than requiring drivers to switch into a separate navigation product. The company added Mongolian voice guidance so newer drivers could navigate unfamiliar routes without creating another distraction.&lt;/p&gt;

&lt;p&gt;This matters because context switching has a cost. Every switch can break the driver’s view of an active job, make state harder to reconcile, and introduce friction exactly when the product needs fast, safe execution.&lt;/p&gt;

&lt;p&gt;For product teams, this suggests a practical checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the driver retain trip context while navigating?&lt;/li&gt;
&lt;li&gt;Are pickup instructions and landmark data available at the moment of arrival?&lt;/li&gt;
&lt;li&gt;Does guidance support the language and mental model of the people using it?&lt;/li&gt;
&lt;li&gt;Can the app learn from a missed pickup without asking operations staff to reconstruct the incident manually?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The reported outcomes are operational, not cosmetic
&lt;/h2&gt;

&lt;p&gt;According to Google Maps Platform’s case study, UBCab reported:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;15%&lt;/strong&gt; increase in driver trip acceptance;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;25%&lt;/strong&gt; reduction in average passenger pickup time, equal to about one minute;&lt;/li&gt;
&lt;li&gt;roughly &lt;strong&gt;540 hours&lt;/strong&gt; of rider waiting time saved per month; and&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;30–40%&lt;/strong&gt; reduction in customer complaints about price and arrival time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are useful metrics because they trace the path from infrastructure to user experience. Better route selection improves driver confidence; more reliable arrival estimates reduce rider uncertainty; both can reduce the operating load placed on support teams.&lt;/p&gt;

&lt;p&gt;They should be read as company-reported outcomes from this deployment, not as universal performance guarantees. The magnitude will depend on a marketplace’s geography, fleet density, road network, data quality, and how well the dispatch logic is integrated into the rest of the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The next challenge is not only routing
&lt;/h2&gt;

&lt;p&gt;UBCab says it plans to extend the approach to food and package delivery, while exploring visual positioning, 3D mapping, and other Google Maps Platform capabilities for international visitors. It also plans to explore generative AI for dispatch workflows and disruption prediction.&lt;/p&gt;

&lt;p&gt;That future work has a familiar product constraint: AI is only useful when it has reliable geography, clear operational boundaries, and a human workflow that can act on its output. A prediction that cannot be connected to a real driver, road, pickup point, or exception-handling process is still just a prediction.&lt;/p&gt;

&lt;p&gt;The UBCab case is therefore bigger than a mapping integration. It is an example of &lt;strong&gt;location intelligence as product infrastructure&lt;/strong&gt;: route data, local address knowledge, navigation, and measurable feedback working together inside a high-stakes operational loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://mapsplatform.google.com/resources/blog/ubcab-reducing-average-passenger-pickup-times-by-25-percent-with-google-maps-platform/" rel="noopener noreferrer"&gt;Google Maps Platform: UBCab case study&lt;/a&gt; — published October 8, 2026.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/maps/documentation/mobility" rel="noopener noreferrer"&gt;Google Maps Platform Mobility services&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/maps/documentation/routes" rel="noopener noreferrer"&gt;Google Maps Routes API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.linkedin.com/posts/mongolias-ubcab-holding-navigated-unique-share-7513977558082801665-nJGL/" rel="noopener noreferrer"&gt;Source LinkedIn post&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devtools</category>
    </item>
    <item>
      <title>Outside Agent: a local-first prompt that gets the screen out of the way</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Fri, 09 Oct 2026 21:43:23 +0000</pubDate>
      <link>https://dev.to/alexshev/outside-agent-a-local-first-prompt-that-gets-the-screen-out-of-the-way-h92</link>
      <guid>https://dev.to/alexshev/outside-agent-a-local-first-prompt-that-gets-the-screen-out-of-the-way-h92</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a draft submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;. It is deliberately marked as a draft: the CLI and its tests are complete, but I have not yet run a locally installed model or published the source repository.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Outside Agent is a small command-line tool that turns a few constraints into one outdoor micro-adventure: a broad area, available time, energy level, and an interest such as birds, plants, or light.&lt;/p&gt;

&lt;p&gt;The point is to make the screen the shortest part of the experience. The model makes a compact plan; the person then leaves the device behind and completes the activity outside.&lt;/p&gt;

&lt;p&gt;The tool intentionally asks only for a broad area. It does not collect an address, track location, check weather, invent trail conditions, or make claims about opening hours or accessibility. Every generated plan tells the user to choose a public, legal, familiar place and verify local conditions themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;The implemented CLI accepts a local Ollama model and a small input set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 outside_agent.py &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--area&lt;/span&gt; &lt;span class="s2"&gt;"a nearby public park"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--minutes&lt;/span&gt; 35 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--energy&lt;/span&gt; low &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--interest&lt;/span&gt; birds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It sends one request to &lt;code&gt;http://127.0.0.1:11434/api/generate&lt;/code&gt; with streaming disabled, then prints the model's Markdown plan.&lt;/p&gt;

&lt;p&gt;The project has automated tests for the user-context prompt, its safety contract, and the local Ollama request shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ran 3 tests in 0.000s
OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I have &lt;strong&gt;not&lt;/strong&gt; run a downloaded model in this environment yet, so I am not presenting a generated outdoor plan as a measured result. A real local-model transcript or video demo is the next required validation before this draft becomes a contest submission.&lt;/p&gt;

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

&lt;p&gt;The initial source is a standard-library Python CLI with a small unit-test suite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;outside_agent.py&lt;/code&gt; — prompt contract, local Ollama request, CLI arguments, and explicit error handling.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tests/test_outside_agent.py&lt;/code&gt; — no-network tests for user-context handling, the safety instructions, and the &lt;code&gt;/api/generate&lt;/code&gt; request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;README.md&lt;/code&gt; — local setup, run command, test command, privacy boundary, and current status.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before publication, I will make this source available in a public repository and add the repository URL here. I will not imply that a private local folder is a public code release.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The open component is the model runtime: Outside Agent uses Ollama to call a locally installed open-weight model. The default model name is &lt;code&gt;gemma3:4b&lt;/code&gt;, but the command accepts any installed model name through &lt;code&gt;--model&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The model is constrained by a system prompt that requires four sections:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mission&lt;/strong&gt; — one practical outdoor activity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What to bring&lt;/strong&gt; — only ordinary, low-risk items.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screen-off cue&lt;/strong&gt; — a clear point when planning ends and the device can stay away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adaptation&lt;/strong&gt; — a bounded fallback if the user's chosen place or conditions are unsuitable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The prompt also forbids local factual claims and medical, emergency, wildlife-handling, or navigation advice. Those are deliberately human decisions, not model outputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For this project, local inference is not a decorative implementation detail. It makes the tool usable without a cloud API key and lets a person choose the model that runs on their own machine. The user controls the model, the runtime, and the small amount of context that leaves the command line.&lt;/p&gt;

&lt;p&gt;That is a better fit for a tool whose purpose is to reduce time on a screen: it should not require a new account, an analytics pipeline, or continuous location collection in order to suggest a short walk, a bird-listening loop, or a plant-observation prompt.&lt;/p&gt;

&lt;p&gt;Open-weight models also make the prompt and safety boundary inspectable. If a community finds a better way to make the planner more useful or more cautious, the implementation is small enough to understand and change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation Still Needed
&lt;/h2&gt;

&lt;p&gt;This draft is not a claim that the model has been evaluated. Before submitting it to the challenge, I will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the CLI against an actually installed local open-weight model.&lt;/li&gt;
&lt;li&gt;Save a real terminal transcript or video demo.&lt;/li&gt;
&lt;li&gt;Publish the source repository.&lt;/li&gt;
&lt;li&gt;Re-read the final post against the challenge rules and publish only if the code, demo, and article all match the evidence.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Beyond the Pin: Building a Production-Ready Google Maps Stack in 2026</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Thu, 10 Sep 2026 17:04:47 +0000</pubDate>
      <link>https://dev.to/alexshev/beyond-the-pin-building-a-production-ready-google-maps-stack-in-2026-480k</link>
      <guid>https://dev.to/alexshev/beyond-the-pin-building-a-production-ready-google-maps-stack-in-2026-480k</guid>
      <description>&lt;p&gt;Most Google Maps integrations begin with one question: &lt;em&gt;How do we put a pin on a map?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the easy part.&lt;/p&gt;

&lt;p&gt;The harder, more valuable problem is building a system that can answer questions such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which nearby locations should a customer see?&lt;/li&gt;
&lt;li&gt;How long will the trip take right now?&lt;/li&gt;
&lt;li&gt;Which parts of a business profile changed, and does the change need attention?&lt;/li&gt;
&lt;li&gt;Is a local-visibility drop a data problem, a competitive problem, or simply a measurement problem?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In 2026, Google Maps Platform is moving developers toward a cleaner answer: use the map for presentation, use the newer web services for computation and place data, and keep product decisions in your own application layer.&lt;/p&gt;

&lt;p&gt;This article walks through that architecture with TypeScript examples. The examples use a local-business use case, but the pattern applies just as well to delivery, marketplaces, store locators, field services, and travel tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change that should trigger an architecture review
&lt;/h2&gt;

&lt;p&gt;On February 25, 2026, Google deprecated &lt;code&gt;google.maps.DirectionsService\&lt;/code&gt;, &lt;code&gt;DirectionsRenderer\&lt;/code&gt;, and &lt;code&gt;DistanceMatrixService\&lt;/code&gt; in the Maps JavaScript API. They still work and Google says they are not currently scheduled for discontinuation, but new projects should use the recommended newer services instead. &lt;a href="https://developers.google.com/maps/documentation/javascript/releases" rel="noopener noreferrer"&gt;Google's release notes&lt;/a&gt; are clear about the direction of travel.&lt;/p&gt;

&lt;p&gt;This is not a reason to rewrite a working map overnight. It &lt;em&gt;is&lt;/em&gt; a reason to stop treating browser-side map services as your routing backend.&lt;/p&gt;

&lt;p&gt;The durable split looks like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;text&lt;br&gt;
Browser&lt;br&gt;
  ├─ Maps JavaScript API: render map, markers, user interactions&lt;br&gt;
  └─ Your backend: authenticated product requests&lt;br&gt;
       ├─ Routes API: route, ETA, route matrix&lt;br&gt;
       ├─ Places API (New): discovery and place details&lt;br&gt;
       ├─ Your database: snapshots, caches, audit history&lt;br&gt;
       └─ Your rules: alerts, prioritization, permissions&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That boundary matters. It keeps API keys and billing-sensitive requests off the client, gives you a single place to add caching and observability, and stops your business rules from being scattered across React components.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Treat the map as a view—not the source of truth
&lt;/h2&gt;

&lt;p&gt;The browser should render the current product state. It should not decide which data is fresh, calculate every route directly, or store your only copy of a business location.&lt;/p&gt;

&lt;p&gt;A useful internal model is deliberately small:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`ts&lt;br&gt;
type PlaceSnapshot = {&lt;br&gt;
  placeId: string;&lt;br&gt;
  displayName: string;&lt;br&gt;
  formattedAddress?: string;&lt;br&gt;
  location?: { lat: number; lng: number };&lt;br&gt;
  primaryType?: string;&lt;br&gt;
  businessStatus?: string;&lt;br&gt;
  fetchedAt: string;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;type RouteSnapshot = {&lt;br&gt;
  originKey: string;&lt;br&gt;
  destinationKey: string;&lt;br&gt;
  travelMode: "DRIVE" | "WALK" | "BICYCLE" | "TRANSIT";&lt;br&gt;
  durationSeconds?: number;&lt;br&gt;
  distanceMeters?: number;&lt;br&gt;
  encodedPolyline?: string;&lt;br&gt;
  fetchedAt: string;&lt;br&gt;
};&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fetchedAt\&lt;/code&gt; is not cosmetic. Place data and travel time are observations, not eternal facts. Once you persist that fact, your UI can say “updated 12 minutes ago,” your jobs can refresh high-value data, and your audit layer can distinguish a new change from an old one.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Move routing behind a server endpoint
&lt;/h2&gt;

&lt;p&gt;The Routes API computes routes over HTTP. Google requires a response field mask, which is a good discipline: request only the fields your feature really needs.&lt;/p&gt;

&lt;p&gt;Here is a minimal Next.js route handler. The API key stays on the server.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`ts&lt;br&gt;
// app/api/route/route.ts&lt;br&gt;
import { NextRequest, NextResponse } from "next/server";&lt;/p&gt;

&lt;p&gt;export async function POST(request: NextRequest) {&lt;br&gt;
  const { origin, destination } = await request.json();&lt;/p&gt;

&lt;p&gt;const response = await fetch(&lt;br&gt;
    "&lt;a href="https://routes.googleapis.com/directions/v2:computeRoutes" rel="noopener noreferrer"&gt;https://routes.googleapis.com/directions/v2:computeRoutes&lt;/a&gt;",&lt;br&gt;
    {&lt;br&gt;
      method: "POST",&lt;br&gt;
      headers: {&lt;br&gt;
        "Content-Type": "application/json",&lt;br&gt;
        "X-Goog-Api-Key": process.env.GOOGLE_MAPS_SERVER_KEY!,&lt;br&gt;
        "X-Goog-FieldMask":&lt;br&gt;
          "routes.duration,routes.distanceMeters,routes.polyline.encodedPolyline",&lt;br&gt;
      },&lt;br&gt;
      body: JSON.stringify({&lt;br&gt;
        origin: { location: { latLng: origin } },&lt;br&gt;
        destination: { location: { latLng: destination } },&lt;br&gt;
        travelMode: "DRIVE",&lt;br&gt;
        routingPreference: "TRAFFIC_AWARE",&lt;br&gt;
        computeAlternativeRoutes: false,&lt;br&gt;
      }),&lt;br&gt;
    },&lt;br&gt;
  );&lt;/p&gt;

&lt;p&gt;if (!response.ok) {&lt;br&gt;
    return NextResponse.json(&lt;br&gt;
      { error: "Route lookup failed" },&lt;br&gt;
      { status: response.status },&lt;br&gt;
    );&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;return NextResponse.json(await response.json());&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The official &lt;a href="https://developers.google.com/maps/documentation/routes/compute_route_directions" rel="noopener noreferrer"&gt;Compute Routes guide&lt;/a&gt; shows the same endpoint and documents the &lt;code&gt;duration\&lt;/code&gt;, &lt;code&gt;distanceMeters\&lt;/code&gt;, and encoded-polyline response fields.&lt;/p&gt;

&lt;p&gt;In production, add four things before calling this “done”:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Input validation.&lt;/strong&gt; Accept numeric latitude/longitude pairs or validated place IDs—never arbitrary unbounded input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cache key.&lt;/strong&gt; Include origin, destination, travel mode, and the parameters that materially affect the response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A short TTL for traffic-aware results.&lt;/strong&gt; An ETA is useful precisely because it changes; avoid serving it as if it were permanent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics.&lt;/strong&gt; Log latency, status, cache hits, and API errors. You cannot manage an expensive dependency by looking only at browser errors.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For many-to-many dispatch or store-selection problems, use Compute Route Matrix rather than firing a large number of individual route requests. The principle is the same: do the work on the backend and persist only the result your product needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Make field masks a product contract
&lt;/h2&gt;

&lt;p&gt;Places API (New) does not return a default payload when you omit a field mask; the request fails. That can feel strict at first, but it forces a useful design conversation: &lt;em&gt;what information does this screen or job actually need?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For example, a location selector may only need a name, address, identifier, and coordinates:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`ts&lt;br&gt;
async function searchPlaces(textQuery: string) {&lt;br&gt;
  const response = await fetch(&lt;br&gt;
    "&lt;a href="https://places.googleapis.com/v1/places:searchText" rel="noopener noreferrer"&gt;https://places.googleapis.com/v1/places:searchText&lt;/a&gt;",&lt;br&gt;
    {&lt;br&gt;
      method: "POST",&lt;br&gt;
      headers: {&lt;br&gt;
        "Content-Type": "application/json",&lt;br&gt;
        "X-Goog-Api-Key": process.env.GOOGLE_MAPS_SERVER_KEY!,&lt;br&gt;
        "X-Goog-FieldMask":&lt;br&gt;
          "places.id,places.displayName,places.formattedAddress,places.location",&lt;br&gt;
      },&lt;br&gt;
      body: JSON.stringify({ textQuery, pageSize: 10 }),&lt;br&gt;
    },&lt;br&gt;
  );&lt;/p&gt;

&lt;p&gt;if (!response.ok) throw new Error("Place search failed");&lt;br&gt;
  return response.json();&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Avoid using &lt;code&gt;*\&lt;/code&gt; outside exploratory work. Google's &lt;a href="https://developers.google.com/maps/documentation/places/web-service/text-search" rel="noopener noreferrer"&gt;Text Search (New) documentation&lt;/a&gt; explicitly warns that wildcard masks can return unnecessary data, increasing processing and billing cost.&lt;/p&gt;

&lt;p&gt;The bigger win is architectural: name your masks and test them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`ts&lt;br&gt;
const PLACE_CARD_FIELDS = [&lt;br&gt;
  "places.id",&lt;br&gt;
  "places.displayName",&lt;br&gt;
  "places.formattedAddress",&lt;br&gt;
  "places.location",&lt;br&gt;
].join(",");&lt;/p&gt;

&lt;p&gt;const PLACE_AUDIT_FIELDS = [&lt;br&gt;
  "places.id",&lt;br&gt;
  "places.displayName",&lt;br&gt;
  "places.primaryType",&lt;br&gt;
  "places.businessStatus",&lt;br&gt;
  "places.formattedAddress",&lt;br&gt;
].join(",");&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Now a pull request that adds a field has an obvious review question: which screen needs it, what is the expected lifecycle, and what does it cost? That is much better than a mystery payload growing in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Model a business as a changing entity
&lt;/h2&gt;

&lt;p&gt;Local products break when they represent a business as &lt;code&gt;name + coordinates + rating\&lt;/code&gt; forever.&lt;/p&gt;

&lt;p&gt;A real-world location can change its hours, categories, services, address, website, phone number, or status. It can move. A service-area business may not have a customer-facing storefront at all. A new business can be relevant before its opening date.&lt;/p&gt;

&lt;p&gt;Those are not edge cases if you are building local search, lead routing, or visibility software. They are the product.&lt;/p&gt;

&lt;p&gt;Start with a refresh policy rather than an assumption that every field has the same freshness requirement:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;ts&lt;br&gt;
const refreshPolicy = {&lt;br&gt;
  placeIdentity: "when user connects or edits a location",&lt;br&gt;
  businessStatus: "daily for monitored locations",&lt;br&gt;
  hours: "daily plus before showing time-sensitive UI",&lt;br&gt;
  competitors: "scheduled scan, not every page view",&lt;br&gt;
  routes: "on demand with a short traffic-aware cache",&lt;br&gt;
} as const;&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The current Places documentation also supports discovery patterns that used to require awkward workarounds. Text Search can include pure service-area businesses with &lt;code&gt;includePureServiceAreaBusinesses\&lt;/code&gt;, and can include future-opening businesses with &lt;code&gt;includeFutureOpeningBusinesses\&lt;/code&gt;. Use those options only when they match the user intent; they are not a substitute for a clearly defined search experience.&lt;/p&gt;

&lt;p&gt;One subtle but important detail: Google notes that identical Text Search requests are not guaranteed to produce a consistent list of places. That means a rank or competitor scan needs a recorded timestamp, query, viewport/bias, and response snapshot. Without those, “we dropped three places” may simply mean “we compared two different observations.”&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Separate collection from decisions
&lt;/h2&gt;

&lt;p&gt;Maps APIs can give your product excellent geographic and place data. They cannot tell an operator what to fix first.&lt;/p&gt;

&lt;p&gt;That is your application’s job.&lt;/p&gt;

&lt;p&gt;For a local-business workflow, the signal pipeline might look like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`text&lt;br&gt;
collect → normalize → compare → explain → queue an action&lt;/p&gt;

&lt;p&gt;GBP/profile data ─┐&lt;br&gt;
map-pack scans   ├─→ per-location snapshot → change detector → action queue&lt;br&gt;
reviews          ┤&lt;br&gt;
competitor data  ┘&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;A small rules layer goes a long way:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`ts&lt;br&gt;
type Finding = {&lt;br&gt;
  key: string;&lt;br&gt;
  impact: "high" | "medium" | "low";&lt;br&gt;
  evidence: string[];&lt;br&gt;
  suggestedAction: string;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;function prioritize(findings: Finding[]) {&lt;br&gt;
  return findings.sort((a, b) =&amp;gt; {&lt;br&gt;
    const weight = { high: 3, medium: 2, low: 1 };&lt;br&gt;
    return weight[b.impact] - weight[a.impact];&lt;br&gt;
  });&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The hard part is not the &lt;code&gt;sort\&lt;/code&gt;. It is preserving evidence: the measured locations, the keyword, the scan time, the competing businesses, and the profile fields that informed the finding. That makes a recommendation reviewable instead of magical.&lt;/p&gt;

&lt;p&gt;This is exactly the operating layer we built &lt;a href="https://seog.ai/" rel="noopener noreferrer"&gt;SEOG&lt;/a&gt; for. SEOG turns public Google Business Profile signals, geo-grid map rankings, reviews, competitor movement, and NAP consistency into a prioritized local-visibility plan—instead of leaving owners with another dashboard to decode. If you are building on Maps APIs, that distinction is useful: the API retrieves data; the product turns observations into accountable next actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A migration checklist for existing Maps JavaScript API apps
&lt;/h2&gt;

&lt;p&gt;If your application still uses &lt;code&gt;DirectionsService\&lt;/code&gt; or &lt;code&gt;DistanceMatrixService\&lt;/code&gt;, do not start by replacing every call mechanically. First find the product intent behind each call.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;“Show the route on the map.”&lt;/strong&gt; Use Routes API for computation; render the returned encoded polyline in your map UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Choose the closest available technician.”&lt;/strong&gt; Use Route Matrix, with caching and a clear freshness window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Find a business from a query.”&lt;/strong&gt; Use Places API (New) with an explicit field mask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“Power a local-visibility dashboard.”&lt;/strong&gt; Store dated snapshots, define a measurement method, and compare like with like.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then work through this implementation checklist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inventory legacy routing calls and identify the user-facing behavior each one supports.&lt;/li&gt;
&lt;li&gt;Create server-side adapters for Routes and Places before changing the UI.&lt;/li&gt;
&lt;li&gt;Restrict API keys by application and API; never expose an unrestricted server key in a browser bundle.&lt;/li&gt;
&lt;li&gt;Define named field masks per feature and test the expected response shape.&lt;/li&gt;
&lt;li&gt;Store observation time, query context, and request parameters alongside results you plan to compare.&lt;/li&gt;
&lt;li&gt;Add caching, retries with sensible limits, error metrics, and budget alerts.&lt;/li&gt;
&lt;li&gt;Roll out behind a feature flag and compare new versus existing behavior before removing the legacy path.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;The modern Google Maps stack is not “a prettier map.” It is a set of services that let you build a reliable local-data system—as long as you keep the right boundaries.&lt;/p&gt;

&lt;p&gt;Render maps in the client. Compute routes and fetch place data through your backend. Ask for only the fields you need. Record when and how you observed local data. And build an operational layer that turns those observations into decisions people can act on.&lt;/p&gt;

&lt;p&gt;If you want to see that last layer applied to Google Business Profile visibility, map-pack rankings, reviews, and local competitors, try the free analysis at &lt;a href="https://seog.ai/" rel="noopener noreferrer"&gt;SEOG&lt;/a&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/maps/documentation/javascript/releases" rel="noopener noreferrer"&gt;Maps JavaScript API release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/maps/documentation/routes/compute_route_directions" rel="noopener noreferrer"&gt;Routes API: Compute Routes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.google.com/maps/documentation/places/web-service/text-search" rel="noopener noreferrer"&gt;Places API (New): Text Search&lt;/a&gt;a&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A Single Google Maps Ranking Is a Bad Metric: Treat Local Visibility as a Spatial Sample</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Wed, 02 Sep 2026 22:37:54 +0000</pubDate>
      <link>https://dev.to/alexshev/a-single-google-maps-ranking-is-a-bad-metric-treat-local-visibility-as-a-spatial-sample-3jf</link>
      <guid>https://dev.to/alexshev/a-single-google-maps-ranking-is-a-bad-metric-treat-local-visibility-as-a-spatial-sample-3jf</guid>
      <description>&lt;p&gt;“We rank #3 on Google Maps” sounds like a measurement.&lt;/p&gt;

&lt;p&gt;Usually, it is an anecdote.&lt;/p&gt;

&lt;p&gt;The result a business sees in Google Maps depends on the query, the searcher’s location, the map viewport, competitors, and the shape of the result page. Checking one keyword from one phone at one location is closer to sampling one pixel than measuring a service area.&lt;/p&gt;

&lt;p&gt;That distinction matters when a local business says calls dropped or an agency reports that a rank “went down.” Before changing a category, rewriting a business name, or buying another citation package, ask a more useful question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Where did visibility change, for which query, and compared with what baseline?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Local rankings are a surface, not a scalar
&lt;/h2&gt;

&lt;p&gt;For a normal web page, a rank tracker can often reduce a query to one result position. Local search is different. Relevance, distance, and prominence interact with the location of the searcher.&lt;/p&gt;

&lt;p&gt;Imagine a plumber with good visibility near its office and weak visibility ten miles away. A single “plumber near me” rank cannot tell you whether the business is losing everywhere, losing only in one neighborhood, or simply being evaluated from a different point.&lt;/p&gt;

&lt;p&gt;Treat the market as a grid of samples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query × coordinate × time → visible result set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal is not to manufacture a perfect score. It is to detect meaningful change without confusing a local variation for a business-wide failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a baseline you can compare
&lt;/h2&gt;

&lt;p&gt;A usable baseline needs more than a position number. For each monitored query, record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the coordinate or neighborhood being sampled;&lt;/li&gt;
&lt;li&gt;the date and approximate time;&lt;/li&gt;
&lt;li&gt;whether the business appeared in the map pack or wider result set;&lt;/li&gt;
&lt;li&gt;the competitors appearing at the same sample point;&lt;/li&gt;
&lt;li&gt;the profile and conversion state at the time: verified status, core facts, phone, booking route, reviews, and linked page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last piece matters. A business can have roughly stable visibility while calls fall because the appointment link broke. It can also appear lower in a few cells because a nearby competitor gained fresh reviews, not because the profile suddenly became invalid.&lt;/p&gt;

&lt;p&gt;Without the surrounding state, a chart invites panic edits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnose the pattern before choosing a fix
&lt;/h2&gt;

&lt;p&gt;The sample pattern narrows the investigation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Start by checking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Drop across most sampled areas&lt;/td&gt;
&lt;td&gt;Profile status, major edits, reviews, website support, or a broader result shift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drop in one neighborhood&lt;/td&gt;
&lt;td&gt;Proximity, a new local competitor, local intent, or service-area mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One service query weakens&lt;/td&gt;
&lt;td&gt;Category, service evidence, and the linked service page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visibility looks stable but calls fall&lt;/td&gt;
&lt;td&gt;Phone, booking path, reviews, and conversion friction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A competitor wins selected cells&lt;/td&gt;
&lt;td&gt;Their category fit, review freshness, distance, and page support&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is not a promise that the cause will be obvious. It is a way to avoid treating every symptom as a category problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not use the grid as an automation trigger
&lt;/h2&gt;

&lt;p&gt;Spatial data helps with diagnosis; it should not blindly alter public business facts.&lt;/p&gt;

&lt;p&gt;The dangerous response to a noisy sample is an automatic change to the business name, address, primary category, or service area. Those fields affect public identity and can create policy or verification risk.&lt;/p&gt;

&lt;p&gt;Safer automation can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run the same samples on a schedule;&lt;/li&gt;
&lt;li&gt;flag cells whose movement exceeds a chosen threshold;&lt;/li&gt;
&lt;li&gt;attach the competing profiles and relevant page evidence;&lt;/li&gt;
&lt;li&gt;identify whether the pattern is local or broad;&lt;/li&gt;
&lt;li&gt;prepare a review queue for an operator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The public change itself should be evidence-backed and owned by someone who can confirm it is true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Think like an observability system
&lt;/h2&gt;

&lt;p&gt;This is the useful analogy for developers: a grid scan is not an alert by itself. It is telemetry.&lt;/p&gt;

&lt;p&gt;Good telemetry becomes useful only with a baseline, a threshold, context, and a response path. A meaningful local-visibility alert might read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;“Emergency plumber” lost map-pack presence in 7 of 9 north-side samples
between the last two weekly checks. The profile is live. Two competitors
gained recent reviews; the service page still points to an outdated booking path.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is actionable. “Rank dropped from 3 to 6” usually is not.&lt;/p&gt;

&lt;p&gt;The action may be to fix a conversion path, verify a service claim, improve real evidence on a page, or simply continue observing. It is not automatically “add keywords.”&lt;/p&gt;

&lt;h2&gt;
  
  
  A lightweight implementation loop
&lt;/h2&gt;

&lt;p&gt;For a local team, keep the loop small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick a few commercial queries and a defensible set of locations.&lt;/li&gt;
&lt;li&gt;Capture the first grid as a baseline, including competitors and profile state.&lt;/li&gt;
&lt;li&gt;Re-run on a consistent cadence.&lt;/li&gt;
&lt;li&gt;Investigate changes that are broad, persistent, or tied to a commercial outcome.&lt;/li&gt;
&lt;li&gt;Record the evidence and owner before a sensitive profile edit.&lt;/li&gt;
&lt;li&gt;Re-sample after the approved change to see whether the pattern actually moved.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://seog.ai" rel="noopener noreferrer"&gt;SEOG&lt;/a&gt; supports this diagnostic workflow with geo-grid map scans, keyword tracking, competitor snapshots, profile checks, and an action-oriented visibility view. The point is not to promise a rank. It is to make the next investigation more precise.&lt;/p&gt;

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

&lt;p&gt;Local visibility is spatial data with business consequences. Treating it as one universal position produces false certainty and bad automation.&lt;/p&gt;

&lt;p&gt;Sample the market deliberately. Compare like with like. Diagnose the shape of the change. Then choose the smallest evidence-backed action that can address the real problem.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: AI assistance was used to draft and edit this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>data</category>
      <category>analytics</category>
    </item>
    <item>
      <title>How to Build a Local-Service Site That Can Answer ‘Can You Fix My RV Today?’</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Sat, 22 Aug 2026 15:16:54 +0000</pubDate>
      <link>https://dev.to/alexshev/how-to-build-a-local-service-site-that-can-answer-can-you-fix-my-rv-today-5ad3</link>
      <guid>https://dev.to/alexshev/how-to-build-a-local-service-site-that-can-answer-can-you-fix-my-rv-today-5ad3</guid>
      <description>&lt;p&gt;An RV repair business does not lose a service call because a visitor failed to read a clever headline.&lt;/p&gt;

&lt;p&gt;It loses the call when a person with a broken slide-out, roof leak, or electrical issue cannot answer four basic questions quickly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Do you handle this exact problem?&lt;/li&gt;
&lt;li&gt;Do you serve where I am?&lt;/li&gt;
&lt;li&gt;Are you available and credible?&lt;/li&gt;
&lt;li&gt;What do I do next?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That sounds like marketing. It is mostly a systems-design problem.&lt;/p&gt;

&lt;p&gt;The implementation goal is not “make more city pages.” It is to make the business's real-world facts available, consistent, crawlable, and usable across the website, Google Business Profile, analytics, and the conversion flow.&lt;/p&gt;

&lt;p&gt;This post turns SEOG’s RV repair checklist into an implementation pattern a developer can apply to any local-service site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: one source of truth, many decision surfaces
&lt;/h2&gt;

&lt;p&gt;Local customers do not encounter a business in one place. They may see a Google result, a Maps profile, a service page, a review, or a call button before they ever submit a form.&lt;/p&gt;

&lt;p&gt;Treat the site as one consumer of a small, canonical business data model rather than a collection of independently written pages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;business facts ─┬─&amp;gt; server-rendered service pages
                ├─&amp;gt; JSON-LD
                ├─&amp;gt; XML sitemap + canonical URLs
                ├─&amp;gt; GBP sync/review queue (with human approval)
                ├─&amp;gt; call/form events
                └─&amp;gt; audit and change history
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is the left side. If a mobile RV technician's phone number, service coverage, repair categories, and hours live in five unrelated CMS fields, a mismatch is inevitable.&lt;/p&gt;

&lt;p&gt;Start with an explicit domain object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;BusinessLocation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;legalName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;publicName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;phoneE164&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;website&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;address&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;streetAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;addressLocality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;addressRegion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;postalCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;addressCountry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&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="nl"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;serviceAreas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;proof&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="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="nl"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dayOfWeek&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="nl"&gt;opens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;closes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;customerProblem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nl"&gt;emergency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;proof&lt;/code&gt; and &lt;code&gt;evidence&lt;/code&gt; are deliberate fields. They prevent the familiar anti-pattern where a generator creates a page merely because a city name and a service name exist in a spreadsheet. A service-area claim should be backed by something real: a dispatch region, technician coverage, case work, a physical facility, or a documented operating policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build pages from real combinations, not every possible combination
&lt;/h2&gt;

&lt;p&gt;A useful URL is not a matrix cell.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/rv-roof-repair/mesa-az&lt;/code&gt; should exist only if the business actually serves Mesa for roof repair and the page can provide information a customer cannot get from a generic page.&lt;/p&gt;

&lt;p&gt;For a Next.js application, make the eligibility rule executable instead of leaving it to editorial memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canPublishServiceAreaPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BusinessLocation&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;services&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BusinessLocation&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;serviceAreas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;proof&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&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;generateStaticParams&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;business&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;getBusinessLocation&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;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&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;service&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;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceAreas&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;canPublishServiceAreaPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;service&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="na"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;slugify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;This is a product guardrail, not an SEO trick. Google’s spam policies explicitly call out substantially similar regional pages that funnel users toward one destination as doorway abuse. A page needs its own user value, not just a different token in the title. &lt;a href="https://developers.google.com/search/docs/essentials/spam-policies#doorway-abuse" rel="noopener noreferrer"&gt;Google’s policy&lt;/a&gt; is worth reading before automating local landing pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Render the decision-critical facts in the initial HTML
&lt;/h2&gt;

&lt;p&gt;Do not make a potential customer — or a crawler — wait for a client-side API call to learn whether you repair RV electrical systems or whether emergency dispatch is available.&lt;/p&gt;

&lt;p&gt;Use server rendering or static generation for the core page content:&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;// app/[service]/[area]/page.tsx&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;ServiceAreaPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&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;business&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;area&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="nf"&gt;resolveServiceArea&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&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;main&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;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; in &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&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;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerProblem&lt;/span&gt;&lt;span class="si"&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;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt; &lt;span class="na"&gt;aria-labelledby&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"coverage"&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"coverage"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Coverage and dispatch&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;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;proof&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="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&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;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;section&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;section&lt;/span&gt; &lt;span class="na"&gt;aria-labelledby&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"proof"&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"proof"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;What we repair&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;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;evidence&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;item&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&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;section&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;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`tel:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phoneE164&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;data-event&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"service_call_click"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Call for &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&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;RequestServiceForm&lt;/span&gt; &lt;span class="na"&gt;serviceId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;areaId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&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;main&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google can render JavaScript, but it processes JavaScript in crawling, rendering, and indexing stages. Server-rendering the essential content improves the experience for people and for crawlers, and helps other bots that do not execute the full application. See Google’s &lt;a href="https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics" rel="noopener noreferrer"&gt;JavaScript SEO guidance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The page should answer the real query, not manufacture confidence. If dispatch availability is unknown, say how the customer gets an answer. Never render “same-day service” because a marketing field was left enabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate structured data from the same object
&lt;/h2&gt;

&lt;p&gt;Schema should be a projection of visible, maintained facts — never a second source of truth.&lt;/p&gt;

&lt;p&gt;For a fixed physical location, use the most specific appropriate &lt;code&gt;LocalBusiness&lt;/code&gt; subtype. For a mobile-only service, do not invent a customer-facing address. Keep the markup aligned with the content a person can see on the 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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;localBusinessJsonLd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BusinessLocation&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@context&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;https://schema.org&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;@type&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AutoRepair&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;LocalBusiness&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;@id&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;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/#location-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;business&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publicName&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;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;telephone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phoneE164&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;address&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="s2"&gt;@type&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;PostalAddress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&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="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;geo&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="s2"&gt;@type&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;GeoCoordinates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;openingHoursSpecification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hours&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;hours&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&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;OpeningHoursSpecification&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;areaServed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceAreas&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;area&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&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;AdministrativeArea&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&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;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;area&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;hasOfferCatalog&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="s2"&gt;@type&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;OfferCatalog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RV repair services&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;itemListElement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&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;service&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&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;Offer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;itemOffered&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="s2"&gt;@type&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;Service&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;
  &lt;span class="na"&gt;dangerouslySetInnerHTML&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;__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="nf"&gt;localBusinessJsonLd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;business&lt;/span&gt;&lt;span class="p"&gt;))&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;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate the final URL with Google’s Rich Results Test and URL Inspection — not only the JSON object in a unit test. Google recommends JSON-LD, but valid markup is an eligibility signal, not a promise of a rich result. Its &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/local-business" rel="noopener noreferrer"&gt;LocalBusiness documentation&lt;/a&gt; is precise about both the implementation and that distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make canonicalization and discovery part of the build
&lt;/h2&gt;

&lt;p&gt;The system should publish only the URLs it wants search engines to treat as canonical.&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;// app/sitemap.ts&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&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;getPublishedServiceAreaPages&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;pages&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;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://example.com/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;area&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;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&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 route also needs a self-referencing canonical in its metadata. Do not put filtered, preview, parameterized, or unpublished pages in the sitemap.&lt;/p&gt;

&lt;p&gt;Google recommends listing the canonical URLs you prefer in a sitemap; for larger sites, generate that sitemap from the same data store that controls publishing. See its &lt;a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap" rel="noopener noreferrer"&gt;sitemap guide&lt;/a&gt; and &lt;a href="https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls" rel="noopener noreferrer"&gt;canonicalization guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrument the conversion path, not only page views
&lt;/h2&gt;

&lt;p&gt;If the business says calls are down, “organic sessions are flat” is not enough diagnosis.&lt;/p&gt;

&lt;p&gt;Record the transition from discovery to action without collecting more personal data than the business needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;LocalIntentEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service_call_click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quote_form_started&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quote_form_submitted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pageType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service_area&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referrerClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;organic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maps&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;direct&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;trackLocalIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LocalIntentEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&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;Track the call click, form start, form submission, and any booking completion that the business can measure. Send only the fields needed for analysis; do not put phone numbers, issue descriptions, or other sensitive request content into generic analytics events.&lt;/p&gt;

&lt;p&gt;Then join those events with a small operational audit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service + area + page version
  -&amp;gt; rendered page checks
  -&amp;gt; profile/review freshness check
  -&amp;gt; call/form events
  -&amp;gt; prioritized human-approved change
  -&amp;gt; re-check window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes it possible to distinguish “we need more demand” from “people are reaching us but the service, coverage, trust, or contact path is unclear.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Add tests where local sites usually drift
&lt;/h2&gt;

&lt;p&gt;The highest-value tests are boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;does not publish a service-area route without service and coverage proof&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canPublishServiceAreaPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;noEvidenceService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;coveredArea&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canPublishServiceAreaPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;realService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;noProofArea&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keeps the phone number consistent across page and JSON-LD&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="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&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;renderPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rv-electrical-repair&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;mesa-az&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+14805550199&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;extractJsonLd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;telephone&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+14805550199&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;Also run an integration check in CI for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;200&lt;/code&gt; responses for published pages and &lt;code&gt;404&lt;/code&gt; for ineligible combinations;&lt;/li&gt;
&lt;li&gt;canonical URL matches the route;&lt;/li&gt;
&lt;li&gt;sitemap contains only published canonical URLs;&lt;/li&gt;
&lt;li&gt;no &lt;code&gt;noindex&lt;/code&gt; leaks from preview/staging configuration;&lt;/li&gt;
&lt;li&gt;JSON-LD reflects visible service, hours, phone, and location data;&lt;/li&gt;
&lt;li&gt;call/form controls exist and are keyboard accessible;&lt;/li&gt;
&lt;li&gt;content changes have a reviewed audit record.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The operating rule: suggest changes, do not silently mutate trust surfaces
&lt;/h2&gt;

&lt;p&gt;A developer can automate discovery, validation, and draft generation. That does not mean an integration should silently rewrite a Google Business Profile, alter service areas, or publish review responses.&lt;/p&gt;

&lt;p&gt;Use an approval queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;observed fact -&amp;gt; proposed correction -&amp;gt; evidence -&amp;gt; risk -&amp;gt; human approval -&amp;gt; apply -&amp;gt; verify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps automation useful without treating a business identity as an unattended configuration file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;The durable local-service implementation is not a pile of keywords and location templates.&lt;/p&gt;

&lt;p&gt;It is a small, tested facts system that makes the same answer available everywhere a customer needs it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We fix this problem. We serve this area. Here is the proof. Here is what to do next.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a better foundation for search visibility, Maps presence, AI-assisted discovery, and — most importantly — a service call that actually reaches the business.&lt;/p&gt;

&lt;p&gt;Original business-facing guide: &lt;a href="https://seog.ai/blog/rv-repair-seo-checklist-service-calls" rel="noopener noreferrer"&gt;RV Repair SEO Checklist: What to Fix When Service Calls Slow Down&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the audit, or start with the evidence
&lt;/h2&gt;

&lt;p&gt;If you are building this workflow for a local-service team, &lt;a href="https://seog.ai" rel="noopener noreferrer"&gt;SEOG&lt;/a&gt; turns the same inputs — Google Business Profile completeness, map visibility, competitor context, reviews, local pages, and conversion gaps — into a prioritized, human-approved action plan.&lt;/p&gt;

&lt;p&gt;Explore the platform: &lt;a href="https://seog.ai" rel="noopener noreferrer"&gt;seog.ai&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>webdev</category>
      <category>seo</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Stop Treating Local SEO Expansion Like a City-Page Generator</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Mon, 10 Aug 2026 20:00:18 +0000</pubDate>
      <link>https://dev.to/alexshev/stop-treating-local-seo-expansion-like-a-city-page-generator-114h</link>
      <guid>https://dev.to/alexshev/stop-treating-local-seo-expansion-like-a-city-page-generator-114h</guid>
      <description>&lt;p&gt;Most local SEO expansion plans start too late in the workflow.&lt;/p&gt;

&lt;p&gt;The client says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;We want more jobs in this suburb.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The team answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create a city page.
Add the city to the service area.
Track a few keywords.
Maybe buy more leads.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That can work sometimes.&lt;/p&gt;

&lt;p&gt;But it is also how teams create doorway pages, make risky Google Business Profile changes, and spend money in markets where the actual blocker was not content.&lt;/p&gt;

&lt;p&gt;The better question is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can we target this city?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The better question is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What local signals say this market is worth pursuing, and what should be fixed before we expand?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is where location intelligence becomes useful for local SEO.&lt;/p&gt;

&lt;p&gt;Not as a fancy map demo.&lt;/p&gt;

&lt;p&gt;As a decision system.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with city-level thinking
&lt;/h2&gt;

&lt;p&gt;Local search is geographic, but many local SEO workflows still behave as if geography is just a keyword modifier.&lt;/p&gt;

&lt;p&gt;They treat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plumber dallas
plumber plano
plumber frisco
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as three content tasks.&lt;/p&gt;

&lt;p&gt;But a local market is not only a keyword.&lt;/p&gt;

&lt;p&gt;It has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;competitor density&lt;/li&gt;
&lt;li&gt;review expectations&lt;/li&gt;
&lt;li&gt;map visibility patterns&lt;/li&gt;
&lt;li&gt;category fit&lt;/li&gt;
&lt;li&gt;service-area reality&lt;/li&gt;
&lt;li&gt;local proof&lt;/li&gt;
&lt;li&gt;customer trust signals&lt;/li&gt;
&lt;li&gt;citation and entity consistency&lt;/li&gt;
&lt;li&gt;website support for the actual service and area&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A business can be strong near its office and invisible ten miles away.&lt;/p&gt;

&lt;p&gt;A service-area business can have a wide radius on paper and still look weak in a specific neighborhood.&lt;/p&gt;

&lt;p&gt;A website can mention a suburb and still fail to prove that the business actually serves it.&lt;/p&gt;

&lt;p&gt;That is why a single rank check from the office is not enough.&lt;/p&gt;

&lt;p&gt;It tells you one viewpoint.&lt;/p&gt;

&lt;p&gt;It does not tell you the market.&lt;/p&gt;




&lt;h2&gt;
  
  
  Location intelligence, in practical terms
&lt;/h2&gt;

&lt;p&gt;For local SEO, I would define location intelligence 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;Turning geographic signals into a prioritized action plan.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounds simple, but it changes the order of work.&lt;/p&gt;

&lt;p&gt;Instead of starting with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What page should we create?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;you start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Where is the business already credible?
Where is it visible but not trusted?
Where is it trusted but not visible?
Where are competitors stronger?
Where does the website fail to support the local promise?
Which fix is likely to matter first?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a much better operating loop.&lt;/p&gt;

&lt;p&gt;It also makes the final recommendation safer.&lt;/p&gt;

&lt;p&gt;Sometimes the answer is still a new local page.&lt;/p&gt;

&lt;p&gt;Sometimes the first fix is review velocity.&lt;/p&gt;

&lt;p&gt;Sometimes it is a Google Business Profile category or service mismatch.&lt;/p&gt;

&lt;p&gt;Sometimes it is citation cleanup.&lt;/p&gt;

&lt;p&gt;Sometimes it is simply: do not expand here yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  A market-area scorecard beats an unordered audit
&lt;/h2&gt;

&lt;p&gt;The most useful version of this workflow is not a giant report.&lt;/p&gt;

&lt;p&gt;It is a market-area scorecard.&lt;/p&gt;

&lt;p&gt;For each target area, compare a small set of signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Market area: North suburb
Primary service: emergency HVAC repair

Profile readiness:
- GBP categories match the service?
- services listed clearly?
- photos and hours current?
- call / booking path visible?

Map visibility:
- visible near the office?
- visible near the target suburb?
- missing for money terms?

Trust:
- review count vs competitors?
- review recency?
- negative reviews unanswered?
- service-specific proof?

Competitors:
- who dominates the local pack?
- are they stronger because of reviews, proximity, category fit, website support, or all of it?

Website support:
- useful service page?
- useful location / area proof?
- internal links?
- conversion path?

Entity consistency:
- name, phone, site, address or service-area signals aligned?
- important directories clean enough?

Recommendation:
- first three fixes
- what not to do yet
- re-check date
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not complicated.&lt;/p&gt;

&lt;p&gt;That is the point.&lt;/p&gt;

&lt;p&gt;A client can understand it.&lt;/p&gt;

&lt;p&gt;An agency can repeat it.&lt;/p&gt;

&lt;p&gt;An operator can re-check it after the fixes.&lt;/p&gt;

&lt;p&gt;And an AI agent can run parts of it if the inputs are structured enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  The dangerous fixes are usually the premature ones
&lt;/h2&gt;

&lt;p&gt;The biggest value of this approach is not that it finds more tasks.&lt;/p&gt;

&lt;p&gt;It stops bad tasks.&lt;/p&gt;

&lt;p&gt;For example, do not start with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;changing a Google Business Profile address or service area without risk review&lt;/li&gt;
&lt;li&gt;creating dozens of thin city pages because competitors have them&lt;/li&gt;
&lt;li&gt;adding keywords to the business name&lt;/li&gt;
&lt;li&gt;buying citations before core entity data is clean&lt;/li&gt;
&lt;li&gt;increasing ad spend before fixing profile, review, and conversion gaps&lt;/li&gt;
&lt;li&gt;assuming one office-location rank check represents the whole market&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is especially common.&lt;/p&gt;

&lt;p&gt;The map is not one result.&lt;/p&gt;

&lt;p&gt;The map is a local surface that changes with the searcher, query, proximity, category fit, competitor strength, and trust signals.&lt;/p&gt;

&lt;p&gt;If you are making expansion decisions, you need area-level evidence.&lt;/p&gt;

&lt;p&gt;Not vibes.&lt;/p&gt;




&lt;h2&gt;
  
  
  A simple priority order
&lt;/h2&gt;

&lt;p&gt;When a client asks, "Should we expand into this area?", I like this order:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Entity mismatch
&lt;/h3&gt;

&lt;p&gt;If the business name, phone, website, address, or service-area signals are inconsistent, fix that before scaling.&lt;/p&gt;

&lt;p&gt;Messy entity data makes everything else less trustworthy.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Profile mismatch
&lt;/h3&gt;

&lt;p&gt;If the Google Business Profile does not clearly support the target service, fix categories, services, hours, photos, and conversion links before writing more pages.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Trust gap
&lt;/h3&gt;

&lt;p&gt;If local competitors have fresher reviews, better photos, clearer services, or stronger ratings, the first fix may be trust, not content.&lt;/p&gt;

&lt;p&gt;Customers often choose from a small local set.&lt;/p&gt;

&lt;p&gt;Small trust differences compound fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Coverage gap
&lt;/h3&gt;

&lt;p&gt;If the site does not support the service-area promise, build or improve the relevant page.&lt;/p&gt;

&lt;p&gt;But make it useful.&lt;/p&gt;

&lt;p&gt;A local page should help a customer choose you, not just repeat a city name.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Competitor gap
&lt;/h3&gt;

&lt;p&gt;If competitors dominate because their profile, reviews, categories, and pages are aligned, you need a market-specific improvement plan.&lt;/p&gt;

&lt;p&gt;Not a generic "local SEO package."&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Measurement gap
&lt;/h3&gt;

&lt;p&gt;If you cannot re-check the same area after changes, the report becomes opinion.&lt;/p&gt;

&lt;p&gt;Track the target area, the first fixes, and the re-check window.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where software should help
&lt;/h2&gt;

&lt;p&gt;This is where I like the SEOG framing.&lt;/p&gt;

&lt;p&gt;The product is not interesting because it promises a magic ranking button.&lt;/p&gt;

&lt;p&gt;That would be the wrong promise.&lt;/p&gt;

&lt;p&gt;The useful promise is more operational:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Run a local visibility preview.
Compare the market-area signals.
Turn the gaps into a prioritized action plan.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the job most local SEO teams actually need help with.&lt;/p&gt;

&lt;p&gt;Not another dashboard full of disconnected metrics.&lt;/p&gt;

&lt;p&gt;Not another spreadsheet where every row has the same urgency.&lt;/p&gt;

&lt;p&gt;A workflow that says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;This market is worth pursuing.
These competitors are the pressure.
These signals are weak.
Fix these three things first.
Do not touch these risky things yet.
Re-check this area after the changes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a stronger client conversation.&lt;/p&gt;

&lt;p&gt;It is also a better internal workflow for agencies.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters for developers and automation teams
&lt;/h2&gt;

&lt;p&gt;If you are building internal tools for marketing teams, this is a good example of a broader product lesson:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Do not only expose data.
Expose decisions.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A local SEO tool can show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rankings&lt;/li&gt;
&lt;li&gt;reviews&lt;/li&gt;
&lt;li&gt;competitors&lt;/li&gt;
&lt;li&gt;profile fields&lt;/li&gt;
&lt;li&gt;citations&lt;/li&gt;
&lt;li&gt;pages&lt;/li&gt;
&lt;li&gt;traffic&lt;/li&gt;
&lt;li&gt;calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the operator still needs to know what to do first.&lt;/p&gt;

&lt;p&gt;The more useful system connects those signals into a decision model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;input signals -&amp;gt; market comparison -&amp;gt; risk checks -&amp;gt; priority order -&amp;gt; action plan -&amp;gt; re-check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the difference between a dashboard and an operating workflow.&lt;/p&gt;

&lt;p&gt;Dashboards are good for visibility.&lt;/p&gt;

&lt;p&gt;Workflows are good for action.&lt;/p&gt;

&lt;p&gt;Local SEO expansion needs both, but the workflow is usually the missing piece.&lt;/p&gt;




&lt;h2&gt;
  
  
  A practical template
&lt;/h2&gt;

&lt;p&gt;Here is the template I would use before recommending expansion into a new local market:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Target area:
Primary service:
Current local visibility:
Strongest three competitors:
Profile gaps:
Review / trust gaps:
Website support gaps:
Citation / entity gaps:
Conversion path gaps:
Risky changes to avoid:
First three fixes:
Re-check date:
Decision:
  - expand now
  - fix first, then expand
  - hold / not worth the effort yet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line matters.&lt;/p&gt;

&lt;p&gt;Not every market deserves action right now.&lt;/p&gt;

&lt;p&gt;Sometimes the best SEO recommendation is sequencing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;Local SEO expansion should not begin as a city-page generator.&lt;/p&gt;

&lt;p&gt;It should begin as a market decision.&lt;/p&gt;

&lt;p&gt;If the evidence says the business is visible, trusted, and supported by the site, then expansion work can make sense.&lt;/p&gt;

&lt;p&gt;If the evidence says the profile is mismatched, reviews are stale, competitors are stronger, and the website does not support the area, more pages may only create noise.&lt;/p&gt;

&lt;p&gt;The better workflow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compare the market -&amp;gt; find the gap -&amp;gt; fix the blocker -&amp;gt; re-check -&amp;gt; expand with evidence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the angle I like in SEOG's location-intelligence checklist.&lt;/p&gt;

&lt;p&gt;It turns local SEO from an unordered pile of tasks into a decision system.&lt;/p&gt;

&lt;p&gt;Original SEOG guide:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://seog.ai/blog/location-intelligence-local-seo-checklist-market-expansion" rel="noopener noreferrer"&gt;https://seog.ai/blog/location-intelligence-local-seo-checklist-market-expansion&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>data</category>
      <category>automation</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Midnight Borscht: CSS Comfort Food Art</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Tue, 04 Aug 2026 05:09:56 +0000</pubDate>
      <link>https://dev.to/alexshev/midnight-borscht-css-comfort-food-art-1in2</link>
      <guid>https://dev.to/alexshev/midnight-borscht-css-comfort-food-art-1in2</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, CSS Art&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspiration
&lt;/h2&gt;

&lt;p&gt;Midnight Borscht is inspired by the kind of comfort food that feels quiet instead of flashy: a deep beet soup, a spoon on the side, rye bread nearby, sour cream melting into the surface, dill scattered on top, and an embroidered tablecloth underneath.&lt;/p&gt;

&lt;p&gt;I wanted the art to feel like a small kitchen after dark. The bowl is warm, the wall tiles are dim, and the tablecloth pattern gives the scene a handmade feeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Live demo: &lt;a href="https://desciple88.github.io/midnight-borscht-css-art-devchallenge-2026/" rel="noopener noreferrer"&gt;https://desciple88.github.io/midnight-borscht-css-art-devchallenge-2026/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/desciple88/midnight-borscht-css-art-devchallenge-2026" rel="noopener noreferrer"&gt;https://github.com/desciple88/midnight-borscht-css-art-devchallenge-2026&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;The constraint for this piece was simple: no images, no SVG, and no JavaScript. Everything is HTML and CSS.&lt;/p&gt;

&lt;p&gt;The bowl, soup surface, sour cream, dill, beet pieces, potato, spoon, bread, wall tiles, and woven tablecloth are all built from layered &lt;code&gt;div&lt;/code&gt; and &lt;code&gt;span&lt;/code&gt; elements with gradients, border radii, shadows, repeated backgrounds, transforms, and pseudo-depth.&lt;/p&gt;

&lt;p&gt;The hardest part was making it read as food at both desktop and mobile sizes. The desktop version has a composed poster feel, while the mobile version keeps the bowl large enough to show the toppings without letting the scene become a random crop.&lt;/p&gt;

&lt;p&gt;CSS details I cared about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The borscht surface uses stacked radial gradients to keep the soup from looking flat.&lt;/li&gt;
&lt;li&gt;The sour cream and dill sit above the beet base to create a clear focal point.&lt;/li&gt;
&lt;li&gt;The tablecloth is made with repeating linear gradients and stitched bands instead of an image.&lt;/li&gt;
&lt;li&gt;The wall tiles use rotated CSS squares so the background still feels handcrafted.&lt;/li&gt;
&lt;li&gt;Steam is animated, but &lt;code&gt;prefers-reduced-motion&lt;/code&gt; disables the motion for users who request it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I self-checked the final piece with local desktop and mobile screenshots, verified that the page contains no image, SVG, or script tags, and deployed it as a public GitHub Pages demo.&lt;/p&gt;

&lt;p&gt;License: MIT.&lt;/p&gt;

</description>
      <category>frontendchallenge</category>
      <category>devchallenge</category>
      <category>css</category>
    </item>
    <item>
      <title>Leftover Ladle: Comfort Food from What You Already Have</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Tue, 04 Aug 2026 04:42:21 +0000</pubDate>
      <link>https://dev.to/alexshev/leftover-ladle-comfort-food-from-what-you-already-have-4h7l</link>
      <guid>https://dev.to/alexshev/leftover-ladle-comfort-food-from-what-you-already-have-4h7l</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, Perfect Landing&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Leftover Ladle is a landing page for a fictional comfort-food service that turns fridge leftovers into a warm dinner plan.&lt;/p&gt;

&lt;p&gt;The theme is built around one very human comfort-food moment: it is late, it is raining, you do not want another delivery app, and the best meal is probably already hiding in your kitchen.&lt;/p&gt;

&lt;p&gt;I focused the page around a complete landing experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A first-viewport hero with a CSS kitchen scene, simmering pot, window, rain, jars, and steam.&lt;/li&gt;
&lt;li&gt;A clear “how it works” section that explains the product without a wall of copy.&lt;/li&gt;
&lt;li&gt;An interactive bowl builder where users choose a base, comfort mood, heat level, and allergy adjustments.&lt;/li&gt;
&lt;li&gt;A concise menu section with three comfort-food directions.&lt;/li&gt;
&lt;li&gt;A reservation form with accessible status feedback.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Live demo: &lt;a href="https://desciple88.github.io/leftover-ladle-devchallenge-2026/" rel="noopener noreferrer"&gt;https://desciple88.github.io/leftover-ladle-devchallenge-2026/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/desciple88/leftover-ladle-devchallenge-2026" rel="noopener noreferrer"&gt;https://github.com/desciple88/leftover-ladle-devchallenge-2026&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;I chose the Perfect Landing prompt because it rewards more than decoration. A good food landing page has to make the visitor hungry, but it also has to feel easy to use.&lt;/p&gt;

&lt;p&gt;The main design choice was to avoid a generic restaurant page. Instead of selling one dish, Leftover Ladle sells a useful comfort-food ritual: pick what is already in your pantry, choose the mood, and get a bowl plan.&lt;/p&gt;

&lt;p&gt;Accessibility and resilience were part of the build from the beginning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Semantic &lt;code&gt;header&lt;/code&gt;, &lt;code&gt;nav&lt;/code&gt;, &lt;code&gt;main&lt;/code&gt;, &lt;code&gt;section&lt;/code&gt;, &lt;code&gt;form&lt;/code&gt;, &lt;code&gt;fieldset&lt;/code&gt;, and &lt;code&gt;footer&lt;/code&gt; structure.&lt;/li&gt;
&lt;li&gt;A skip link for keyboard users.&lt;/li&gt;
&lt;li&gt;Real labels for controls and inputs.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;aria-live&lt;/code&gt; bowl preview so updates are announced.&lt;/li&gt;
&lt;li&gt;High-contrast buttons and focus states.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prefers-reduced-motion&lt;/code&gt; handling for the rain and steam animation.&lt;/li&gt;
&lt;li&gt;No runtime dependencies, so the page stays fast and easy to inspect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The part I am happiest with is the CSS kitchen scene. It gives the page its own visual identity without relying on a stock food photo, and it still supports the landing page rather than becoming a separate art experiment.&lt;/p&gt;

&lt;p&gt;I also self-checked the project with desktop and mobile screenshots, static HTML checks for duplicate IDs and label targets, and a JavaScript syntax check before submitting.&lt;/p&gt;

&lt;p&gt;License: MIT.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>frontendchallenge</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How I Debugged a Broken Vertex AI Image Pipeline and Finally Made It Reliable</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Mon, 03 Aug 2026 16:47:18 +0000</pubDate>
      <link>https://dev.to/alexshev/how-i-debugged-a-broken-vertex-ai-image-pipeline-and-finally-made-it-reliable-5557</link>
      <guid>https://dev.to/alexshev/how-i-debugged-a-broken-vertex-ai-image-pipeline-and-finally-made-it-reliable-5557</guid>
      <description>&lt;p&gt;Last week, I lost more time to AI infrastructure than to the actual creative work.&lt;/p&gt;

&lt;p&gt;The task sounded simple: generate images through Vertex AI for a content pipeline.&lt;/p&gt;

&lt;p&gt;What actually happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one key worked yesterday and failed today&lt;/li&gt;
&lt;li&gt;one project returned &lt;code&gt;invalid_grant&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;another returned &lt;code&gt;permission denied&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;free tier worked sometimes, then hit quota walls&lt;/li&gt;
&lt;li&gt;the code looked fine, but the system still didn’t produce images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the part nobody tells you about AI workflows:&lt;br&gt;
&lt;strong&gt;most failures are not model failures. They’re credential, project, and policy failures.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here’s how I finally debugged the whole thing and got the image route working again.&lt;/p&gt;


&lt;h2&gt;
  
  
  The symptoms
&lt;/h2&gt;

&lt;p&gt;At first, the failures looked unrelated.&lt;/p&gt;

&lt;p&gt;I saw three different classes of errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid_grant: account not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;403 Permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;429 RESOURCE_EXHAUSTED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That usually means one of two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;your system is broken in multiple places&lt;/li&gt;
&lt;li&gt;your system is pointing at multiple environments and you don’t know which one is real&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In my case, it was the second one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Stop guessing which path is canonical
&lt;/h2&gt;

&lt;p&gt;The first useful move was brutally simple:&lt;br&gt;
find the one script that the team actually trusts.&lt;/p&gt;

&lt;p&gt;For us, that was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/clawd/ops/production/scripts/generate_panels.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That became the source of truth.&lt;/p&gt;

&lt;p&gt;Not old snippets.&lt;br&gt;
Not half-working notebooks.&lt;br&gt;
Not memory.&lt;/p&gt;

&lt;p&gt;Once I checked the actual script, I immediately found one hidden problem:&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;PROJECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;old-project-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline was still hardcoded to an old project.&lt;/p&gt;

&lt;p&gt;So even when I updated the credentials, the requests were still going to the wrong place.&lt;/p&gt;

&lt;p&gt;That alone explained a lot.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Separate free-tier failure from paid-route failure
&lt;/h2&gt;

&lt;p&gt;We had two different routes mixed together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gemini API / AI Studio free tier&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vertex AI paid route&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sounds harmless, but it creates terrible debugging conditions.&lt;/p&gt;

&lt;p&gt;Because the failure modes are different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;free tier dies with quota errors&lt;/li&gt;
&lt;li&gt;Vertex dies with IAM / service account / project errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you mix them, you start solving the wrong problem.&lt;/p&gt;

&lt;p&gt;For example, this looked like a model problem at first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;429 RESOURCE_EXHAUSTED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But it turned out to be just a burned free-tier key.&lt;/p&gt;

&lt;p&gt;Meanwhile the paid route was failing for a completely different reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; treat free and paid as separate systems, even if they use the same model.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Verify the service account before touching the code
&lt;/h2&gt;

&lt;p&gt;Once I had the new Vertex JSON, I didn’t start by generating an image.&lt;br&gt;
I started by checking whether the credential could even mint a token.&lt;/p&gt;

&lt;p&gt;That kind of test saves time because it tells you whether the problem is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;auth&lt;/li&gt;
&lt;li&gt;project permissions&lt;/li&gt;
&lt;li&gt;or model invocation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Python, the logic is basically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.oauth2&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;service_account&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.auth.transport.requests&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;

&lt;span class="n"&gt;creds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;service_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_service_account_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vertex_ai_key.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;scopes&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;https://www.googleapis.com/auth/cloud-platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this step fails, don’t touch your prompts.&lt;br&gt;
Don’t touch the model.&lt;br&gt;
Don’t touch the rendering code.&lt;/p&gt;

&lt;p&gt;You don’t have an image problem yet.&lt;br&gt;
You have an auth problem.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 4: Watch for organization policies
&lt;/h2&gt;

&lt;p&gt;This one burned the most time.&lt;/p&gt;

&lt;p&gt;I created a new service account, everything looked correct, and then Google Cloud refused to create a JSON key.&lt;/p&gt;

&lt;p&gt;The error turned out to be caused by this policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iam.disableServiceAccountKeyCreation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That wasn’t obvious from the first screen.&lt;br&gt;
The UI showed one policy as “not enforced,” while a legacy constraint was still active somewhere above it.&lt;/p&gt;

&lt;p&gt;That kind of mismatch is why cloud debugging feels cursed.&lt;/p&gt;

&lt;p&gt;The practical fix was not to keep fighting the same project.&lt;br&gt;
The practical fix was to create a clean personal project without inherited org-policy baggage.&lt;/p&gt;

&lt;p&gt;That ended up being faster than trying to untangle admin policy state.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 5: Create one clean project and move on
&lt;/h2&gt;

&lt;p&gt;The final working setup looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a new clean Vertex project&lt;/li&gt;
&lt;li&gt;a new service account&lt;/li&gt;
&lt;li&gt;a fresh JSON key&lt;/li&gt;
&lt;li&gt;the canonical script updated to the new project id&lt;/li&gt;
&lt;li&gt;one successful test generation to prove the whole path worked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after that did I consider the route fixed.&lt;/p&gt;

&lt;p&gt;Not when the key existed.&lt;br&gt;
Not when the policy screen looked green.&lt;br&gt;
Not when the script stopped crashing.&lt;/p&gt;

&lt;p&gt;Only when this produced an actual file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;outputs/nanobanana_vertex_test.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the only result that mattered.&lt;/p&gt;




&lt;h2&gt;
  
  
  The working mental model
&lt;/h2&gt;

&lt;p&gt;When an AI image pipeline breaks, I now check things in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Which script is canonical?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Which project is the request actually hitting?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Can the credential mint a token?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is this free-tier quota or Vertex IAM?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Are org policies blocking service-account keys?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Did I generate one real image successfully?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That order is much faster than randomly changing keys and re-running prompts.&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually fixed it
&lt;/h2&gt;

&lt;p&gt;For us, the final fix was not “better prompting.”&lt;br&gt;
It was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stopping reliance on stale project ids&lt;/li&gt;
&lt;li&gt;replacing broken credentials&lt;/li&gt;
&lt;li&gt;separating free-tier and paid routes&lt;/li&gt;
&lt;li&gt;avoiding inherited org-policy traps&lt;/li&gt;
&lt;li&gt;testing the pipeline end-to-end with a real output file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s not glamorous.&lt;br&gt;
But it’s the difference between a pipeline you trust and a pipeline that only works when you’re lucky.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;A lot of AI tooling discourse is still obsessed with models.&lt;/p&gt;

&lt;p&gt;But once you work with these systems in production, the real bottleneck is often much more boring:&lt;br&gt;
&lt;strong&gt;identity, permissions, quotas, and project hygiene.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model can be state of the art.&lt;br&gt;
If your project graph is a mess, you still won’t ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Terminal Skills fits
&lt;/h2&gt;

&lt;p&gt;This is also exactly the kind of workflow I want to turn into a Terminal Skill.&lt;/p&gt;

&lt;p&gt;Not because a skill should hide the cloud setup behind magic, but because the debug order should not live only in someone’s memory.&lt;/p&gt;

&lt;p&gt;A useful &lt;code&gt;vertex-ai-image-pipeline&lt;/code&gt; skill would give an agent a repeatable checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;identify the canonical script&lt;/li&gt;
&lt;li&gt;verify the configured project id&lt;/li&gt;
&lt;li&gt;test whether the service account can mint a token&lt;/li&gt;
&lt;li&gt;separate free-tier quota failures from Vertex IAM failures&lt;/li&gt;
&lt;li&gt;check for organization-policy blockers&lt;/li&gt;
&lt;li&gt;run one minimal generation test before touching prompts&lt;/li&gt;
&lt;li&gt;report the exact failing layer instead of guessing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the broader idea behind &lt;a href="https://terminalskills.io" rel="noopener noreferrer"&gt;Terminal Skills&lt;/a&gt;: turn messy, real operational workflows into reusable agent skills.&lt;/p&gt;

&lt;p&gt;I will probably translate this article into a proper Terminal Skills use case next, because this is the kind of boring production workflow agents need more than another prompt template.&lt;/p&gt;

&lt;p&gt;If you’ve had to debug a broken AI pipeline recently, I’d genuinely love to hear what failed first for you.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devtools</category>
      <category>googlecloud</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Stop Testing APIs by Hand. Give Your AI Agent a Bruno Workflow.</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Wed, 29 Jul 2026 23:25:57 +0000</pubDate>
      <link>https://dev.to/alexshev/stop-testing-apis-by-hand-give-your-ai-agent-a-bruno-workflow-3gk2</link>
      <guid>https://dev.to/alexshev/stop-testing-apis-by-hand-give-your-ai-agent-a-bruno-workflow-3gk2</guid>
      <description>&lt;p&gt;Most API testing workflows break in a boring way.&lt;/p&gt;

&lt;p&gt;Not because the team does not care about testing.&lt;/p&gt;

&lt;p&gt;Not because nobody knows how to send a request.&lt;/p&gt;

&lt;p&gt;Because the API workflow lives in too many places at once.&lt;/p&gt;

&lt;p&gt;One developer has a Postman collection.&lt;br&gt;
Another has a few curl commands in shell history.&lt;br&gt;
The backend repo has environment variables in &lt;code&gt;.env.example&lt;/code&gt;.&lt;br&gt;
CI has a smoke test that only checks one endpoint.&lt;br&gt;
The docs say one thing.&lt;br&gt;
The staging API behaves slightly differently.&lt;/p&gt;

&lt;p&gt;Then an AI coding agent enters the repo and gets asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can you check this API flow?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent can read code.&lt;br&gt;
It can write tests.&lt;br&gt;
It can generate curl commands.&lt;br&gt;
It can explain HTTP status codes.&lt;/p&gt;

&lt;p&gt;But it still has the same problem a new human has.&lt;/p&gt;

&lt;p&gt;Where is the API workflow?&lt;/p&gt;

&lt;p&gt;If the answer is "somewhere in a GUI tool, someone's local history, and a few scattered docs," the agent has to reconstruct the whole thing from scratch.&lt;/p&gt;

&lt;p&gt;That is not a tool problem.&lt;/p&gt;

&lt;p&gt;That is a workflow packaging problem.&lt;/p&gt;


&lt;h2&gt;
  
  
  API collections should live near the code
&lt;/h2&gt;

&lt;p&gt;This is why I like Bruno.&lt;/p&gt;

&lt;p&gt;Bruno is an open-source API client that stores collections as plain text files in your Git repository.&lt;/p&gt;

&lt;p&gt;That detail matters more than the UI.&lt;/p&gt;

&lt;p&gt;If your API collection lives next to the application code, it becomes part of the engineering system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request changes show up in pull requests&lt;/li&gt;
&lt;li&gt;environment files can be reviewed&lt;/li&gt;
&lt;li&gt;auth flows can be documented as runnable requests&lt;/li&gt;
&lt;li&gt;test assertions can live with the request that needs them&lt;/li&gt;
&lt;li&gt;CI can run the same collection humans use locally&lt;/li&gt;
&lt;li&gt;new team members can learn the API by running the collection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The collection is not a private artifact hidden in a synced workspace.&lt;/p&gt;

&lt;p&gt;It is a repo artifact.&lt;/p&gt;

&lt;p&gt;That changes what an AI agent can do with it.&lt;/p&gt;


&lt;h2&gt;
  
  
  The real unit is not a request
&lt;/h2&gt;

&lt;p&gt;A single API request is easy.&lt;/p&gt;

&lt;p&gt;The useful workflow is usually a sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Login.&lt;/li&gt;
&lt;li&gt;Save the token.&lt;/li&gt;
&lt;li&gt;Create a resource.&lt;/li&gt;
&lt;li&gt;Read it back.&lt;/li&gt;
&lt;li&gt;Update it.&lt;/li&gt;
&lt;li&gt;Verify the response shape.&lt;/li&gt;
&lt;li&gt;Clean up or test the failure path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When developers test this manually, they often remember the sequence in their head.&lt;/p&gt;

&lt;p&gt;That works until the API changes.&lt;/p&gt;

&lt;p&gt;It works until staging has different data.&lt;/p&gt;

&lt;p&gt;It works until someone else needs to debug the same flow.&lt;/p&gt;

&lt;p&gt;It works until an agent has to run it and cannot rely on tribal memory.&lt;/p&gt;

&lt;p&gt;The important thing to encode is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Send a POST request.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;This is the auth flow.
These variables move from one request to the next.
These assertions prove the contract still works.
This command runs the flow in CI.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is where Bruno's file-based model becomes useful.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;.bru&lt;/code&gt; request can contain the request metadata, headers, body, scripts, and tests in a human-readable format.&lt;/p&gt;

&lt;p&gt;An environment file can define &lt;code&gt;baseUrl&lt;/code&gt;, test users, and secret placeholders.&lt;/p&gt;

&lt;p&gt;Post-response scripts can save values for later requests.&lt;/p&gt;

&lt;p&gt;The CLI can run the collection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bru run &lt;span class="nt"&gt;--env&lt;/span&gt; staging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or a folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bru run auth/ &lt;span class="nt"&gt;--env&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or output CI-friendly results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bru run &lt;span class="nt"&gt;--env&lt;/span&gt; dev &lt;span class="nt"&gt;--output&lt;/span&gt; results.xml &lt;span class="nt"&gt;--format&lt;/span&gt; junit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a much better artifact for an agent than a vague instruction like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Test the API.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What an AI agent should not improvise
&lt;/h2&gt;

&lt;p&gt;I do not want an agent inventing an API testing workflow every time.&lt;/p&gt;

&lt;p&gt;That is how you get impressive-looking activity and weak verification.&lt;/p&gt;

&lt;p&gt;The agent might:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hit the happy path only&lt;/li&gt;
&lt;li&gt;test against the wrong environment&lt;/li&gt;
&lt;li&gt;forget auth state&lt;/li&gt;
&lt;li&gt;use fake data that bypasses the real bug&lt;/li&gt;
&lt;li&gt;skip assertions and only report that the request returned something&lt;/li&gt;
&lt;li&gt;paste a curl command that nobody ever runs again&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those failures look dramatic.&lt;/p&gt;

&lt;p&gt;They look like work.&lt;/p&gt;

&lt;p&gt;The agent says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I tested the endpoint.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the team has no durable artifact, no diff, no reusable flow, and no CI hook.&lt;/p&gt;

&lt;p&gt;That is the wrong end state.&lt;/p&gt;

&lt;p&gt;For API work, I want the agent to leave behind something the repo can keep.&lt;/p&gt;




&lt;h2&gt;
  
  
  A better Bruno workflow for agents
&lt;/h2&gt;

&lt;p&gt;The workflow I would want from an AI coding agent looks like this.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Discover the API surface
&lt;/h3&gt;

&lt;p&gt;The agent should inspect routes, controllers, OpenAPI specs if they exist, existing tests, and any current API docs.&lt;/p&gt;

&lt;p&gt;The output should be a short map:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;auth endpoints&lt;/li&gt;
&lt;li&gt;core resources&lt;/li&gt;
&lt;li&gt;destructive actions&lt;/li&gt;
&lt;li&gt;environment assumptions&lt;/li&gt;
&lt;li&gt;test data requirements&lt;/li&gt;
&lt;li&gt;flows that need chained state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No collection changes yet.&lt;/p&gt;

&lt;p&gt;First understand the shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Create a Git-friendly collection structure
&lt;/h3&gt;

&lt;p&gt;The collection should mirror the API, not the agent's thought process.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api-collection/
  bruno.json
  environments/
    dev.bru
    staging.bru
  auth/
    login.bru
    refresh-token.bru
  users/
    list-users.bru
    get-user.bru
    create-user.bru
  orders/
    create-order.bru
    process-refund.bru
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That structure is boring on purpose.&lt;/p&gt;

&lt;p&gt;People can navigate it.&lt;/p&gt;

&lt;p&gt;Agents can navigate it.&lt;/p&gt;

&lt;p&gt;Reviewers can diff it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Encode variables and secrets correctly
&lt;/h3&gt;

&lt;p&gt;Environment files should carry normal config like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;baseUrl: http://localhost:3000
testEmail: test@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Secrets should be marked as secrets and not committed as plain values.&lt;/p&gt;

&lt;p&gt;This is one of the places where an agent needs a rule, not vibes.&lt;/p&gt;

&lt;p&gt;If a tool can run requests, it can also leak credentials by accident.&lt;/p&gt;

&lt;p&gt;So the workflow has to define where config lives, which values can be committed, and what must stay local or injected by CI.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Chain requests deliberately
&lt;/h3&gt;

&lt;p&gt;Many real API tests are stateful.&lt;/p&gt;

&lt;p&gt;Login returns a token.&lt;/p&gt;

&lt;p&gt;Create user returns an ID.&lt;/p&gt;

&lt;p&gt;Create order depends on the user.&lt;/p&gt;

&lt;p&gt;Refund depends on the order.&lt;/p&gt;

&lt;p&gt;The agent should not fake those IDs when it can capture them from the real response.&lt;/p&gt;

&lt;p&gt;That is where post-response scripts are useful.&lt;/p&gt;

&lt;p&gt;The point is not clever scripting.&lt;/p&gt;

&lt;p&gt;The point is making the API flow executable instead of explanatory.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Put assertions next to the request
&lt;/h3&gt;

&lt;p&gt;A request without assertions is only a smoke signal.&lt;/p&gt;

&lt;p&gt;The agent should add tests for the contract that matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;status code&lt;/li&gt;
&lt;li&gt;required fields&lt;/li&gt;
&lt;li&gt;expected types&lt;/li&gt;
&lt;li&gt;auth behavior&lt;/li&gt;
&lt;li&gt;error shape&lt;/li&gt;
&lt;li&gt;permission boundaries&lt;/li&gt;
&lt;li&gt;idempotency or retry behavior when relevant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test should answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What would make this endpoint unsafe to ship?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Did the server return any JSON?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. Add a CI command
&lt;/h3&gt;

&lt;p&gt;The workflow is not done until there is a command the team can run again.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bru run &lt;span class="nt"&gt;--env&lt;/span&gt; staging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bru run auth/ &lt;span class="nb"&gt;users&lt;/span&gt;/ &lt;span class="nt"&gt;--env&lt;/span&gt; ci &lt;span class="nt"&gt;--output&lt;/span&gt; results.xml &lt;span class="nt"&gt;--format&lt;/span&gt; junit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact command depends on the project.&lt;/p&gt;

&lt;p&gt;But the artifact should be clear enough that a developer, CI job, or future agent can repeat the same check.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Terminal Skills fits
&lt;/h2&gt;

&lt;p&gt;This is exactly the kind of workflow that belongs in a skill.&lt;/p&gt;

&lt;p&gt;The Terminal Skills catalog has a &lt;code&gt;bruno&lt;/code&gt; skill for AI agents:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://terminalskills.io/skills/bruno" rel="noopener noreferrer"&gt;Bruno on Terminal Skills&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The useful part is not "the agent has heard of Bruno."&lt;/p&gt;

&lt;p&gt;That is not enough.&lt;/p&gt;

&lt;p&gt;The useful part is giving the agent an operating procedure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use Bruno as a Git-first API testing tool&lt;/li&gt;
&lt;li&gt;store collections as reviewable repo files&lt;/li&gt;
&lt;li&gt;organize requests by API structure&lt;/li&gt;
&lt;li&gt;use environment files for config&lt;/li&gt;
&lt;li&gt;keep secrets out of committed values&lt;/li&gt;
&lt;li&gt;write assertions in requests&lt;/li&gt;
&lt;li&gt;chain state between calls intentionally&lt;/li&gt;
&lt;li&gt;run collections through the Bruno CLI&lt;/li&gt;
&lt;li&gt;treat the collection as living API documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Install for Codex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx terminal-skills &lt;span class="nb"&gt;install &lt;/span&gt;bruno &lt;span class="nt"&gt;--agent&lt;/span&gt; codex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install for Gemini CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx terminal-skills &lt;span class="nb"&gt;install &lt;/span&gt;bruno &lt;span class="nt"&gt;--agent&lt;/span&gt; gemini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install for Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx terminal-skills &lt;span class="nb"&gt;install &lt;/span&gt;bruno
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broader point is simple:&lt;/p&gt;

&lt;p&gt;If an agent is going to touch your API workflow, do not ask it to improvise testing.&lt;/p&gt;

&lt;p&gt;Give it a repeatable workflow it can follow, edit, run, and leave behind.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters for API teams
&lt;/h2&gt;

&lt;p&gt;API testing is one of those areas where AI can create false confidence quickly.&lt;/p&gt;

&lt;p&gt;An agent can generate a convincing explanation of an endpoint.&lt;/p&gt;

&lt;p&gt;It can generate sample requests.&lt;/p&gt;

&lt;p&gt;It can say the test passed.&lt;/p&gt;

&lt;p&gt;But unless the workflow leaves a durable artifact, the value disappears after the chat.&lt;/p&gt;

&lt;p&gt;The better outcome is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Bruno collection was updated&lt;/li&gt;
&lt;li&gt;the change is visible in Git&lt;/li&gt;
&lt;li&gt;the request has assertions&lt;/li&gt;
&lt;li&gt;the environment is explicit&lt;/li&gt;
&lt;li&gt;the command can run locally&lt;/li&gt;
&lt;li&gt;CI can run it later&lt;/li&gt;
&lt;li&gt;the next agent does not need to rediscover the flow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the difference between assistance and operational memory.&lt;/p&gt;

&lt;p&gt;The agent did not just test the API.&lt;/p&gt;

&lt;p&gt;It improved how the team tests the API.&lt;/p&gt;




&lt;h2&gt;
  
  
  My practical checklist
&lt;/h2&gt;

&lt;p&gt;If I were reviewing an agent-generated Bruno workflow, I would ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the collection live in the repo?&lt;/li&gt;
&lt;li&gt;Does the folder structure match the API shape?&lt;/li&gt;
&lt;li&gt;Are environments separated cleanly?&lt;/li&gt;
&lt;li&gt;Are secrets marked and excluded from committed values?&lt;/li&gt;
&lt;li&gt;Do important requests include assertions?&lt;/li&gt;
&lt;li&gt;Are auth tokens and IDs passed through real response state?&lt;/li&gt;
&lt;li&gt;Can the collection run from the CLI?&lt;/li&gt;
&lt;li&gt;Is there a CI-ready command?&lt;/li&gt;
&lt;li&gt;Would a new teammate understand the API faster after reading and running it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the answer is yes, the agent left behind something useful.&lt;/p&gt;

&lt;p&gt;If the answer is no, it probably just tested by hand with extra steps.&lt;/p&gt;




&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;AI agents are good at producing one-off work.&lt;/p&gt;

&lt;p&gt;Engineering teams need repeatable work.&lt;/p&gt;

&lt;p&gt;That is the gap skills are meant to close.&lt;/p&gt;

&lt;p&gt;For API testing, the difference is especially visible.&lt;/p&gt;

&lt;p&gt;Do you want the agent to send a request once?&lt;/p&gt;

&lt;p&gt;Or do you want it to build the API workflow your team can keep using?&lt;/p&gt;

&lt;p&gt;That is why Bruno is a good fit for Terminal Skills.&lt;/p&gt;

&lt;p&gt;It turns API testing into files, diffs, commands, and checks.&lt;/p&gt;

&lt;p&gt;And those are exactly the kinds of artifacts agents can use without asking everyone to trust a chat transcript.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: AI assistance was used to draft and edit this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>devtools</category>
      <category>ai</category>
    </item>
    <item>
      <title>I fixed an unbounded audio queue in a live translation server before it could take the process down</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Wed, 15 Jul 2026 01:51:04 +0000</pubDate>
      <link>https://dev.to/alexshev/i-fixed-an-unbounded-audio-queue-in-a-live-translation-server-before-it-could-take-the-process-down-i6m</link>
      <guid>https://dev.to/alexshev/i-fixed-an-unbounded-audio-queue-in-a-live-translation-server-before-it-could-take-the-process-down-i6m</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;The project is a live speech translation app.&lt;/p&gt;

&lt;p&gt;A browser client captures microphone audio, converts it to 16 kHz PCM, and streams it to a NestJS server over Socket.IO. The server opens one WebSocket session to a local ML service, forwards the audio frames, and relays translated segments back to listeners.&lt;/p&gt;

&lt;p&gt;The important part for this bug: audio starts arriving from the browser before the ML WebSocket is guaranteed to be open.&lt;/p&gt;

&lt;p&gt;That is normal for realtime systems. The dangerous part was what happened when the ML service was unavailable, slow to handshake, or stuck behind a bad connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;MlSession&lt;/code&gt; class buffered audio frames while waiting for the ML WebSocket to open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;sendAudio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&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;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="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;open&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;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&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;pcm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks harmless until you remember what the data is: raw PCM audio.&lt;/p&gt;

&lt;p&gt;At 16 kHz, mono, 16-bit PCM, the stream is roughly 32 KB per second per active speaker. If the ML WebSocket never opens, every &lt;code&gt;audio&lt;/code&gt; event keeps appending another &lt;code&gt;Buffer&lt;/code&gt; to &lt;code&gt;queue&lt;/code&gt;. A stalled ML service could quietly turn one active audio source into an unbounded memory growth path.&lt;/p&gt;

&lt;p&gt;The server did not have to be under heavy traffic for this to matter. One broadcaster with a stuck ML backend was enough to create an ever-growing in-memory queue.&lt;/p&gt;

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

&lt;p&gt;I added an explicit cap for queued audio before the ML connection opens, tracked queued bytes, reset the counter once the connection opens, and released queued buffers on close/error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_QUEUED_AUDIO_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&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;ML_MAX_QUEUED_AUDIO_BYTES&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&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 send path now fails fast instead of buffering forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;sendAudio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&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;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="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;open&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;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;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;queuedBytes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_QUEUED_AUDIO_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;finish&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ML websocket audio queue overflow before connection opened&lt;/span&gt;&lt;span class="dl"&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
      &lt;span class="k"&gt;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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&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;pcm&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;queuedBytes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&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;When the socket opens, queued audio is flushed and the byte counter goes back to zero:&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&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="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;config&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;config&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;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;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;buf&lt;/span&gt; &lt;span class="k"&gt;of&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;queue&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;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&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;queue&lt;/span&gt; &lt;span class="o"&gt;=&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;queuedBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Close/error paths also clear the queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&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;closed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&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;queuedBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;finish&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="nb"&gt;Error&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;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&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;queuedBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onClose&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;The fix changes the failure mode.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;ML service unavailable or stuck&lt;/li&gt;
&lt;li&gt;browser keeps streaming audio&lt;/li&gt;
&lt;li&gt;server queues every PCM frame&lt;/li&gt;
&lt;li&gt;memory grows until the process is restarted or killed&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;ML service unavailable or stuck&lt;/li&gt;
&lt;li&gt;server accepts only a bounded amount of pending audio&lt;/li&gt;
&lt;li&gt;session closes with a specific overflow error&lt;/li&gt;
&lt;li&gt;existing gateway cleanup runs and listeners get the normal ended signal&lt;/li&gt;
&lt;li&gt;memory is released immediately&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I kept the default cap at &lt;code&gt;512 KB&lt;/code&gt;, which is around 16 seconds of 16 kHz mono PCM audio. That is enough for normal connection delay but small enough to prevent a single stalled session from becoming a memory leak.&lt;/p&gt;

&lt;p&gt;The value is configurable:&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;ML_MAX_QUEUED_AUDIO_BYTES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1048576
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lets production tune the buffer based on expected network conditions without changing code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;The normal server build still passes:&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;/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 plaintext"&gt;&lt;code&gt;&amp;gt; live-translation-server@0.1.0 build
&amp;gt; tsc -p tsconfig.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also ran a smoke test that accepts the TCP connection but intentionally never completes the WebSocket handshake. Then I sent audio chunks into &lt;code&gt;MlSession&lt;/code&gt; with a small queue limit.&lt;/p&gt;

&lt;p&gt;Expected result: the session should close with the overflow error instead of keeping the buffers forever.&lt;/p&gt;

&lt;p&gt;Observed result:&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="nl"&gt;"closed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"errMessage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ML websocket audio queue overflow before connection opened"&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;That is the behavior I wanted: when the ML backend is not actually ready to receive audio, the realtime server fails the session explicitly instead of becoming a quiet memory sink.&lt;/p&gt;

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

&lt;p&gt;Realtime systems make small buffers feel safe.&lt;/p&gt;

&lt;p&gt;This was not a dramatic bug in the happy path. The app worked when the ML WebSocket opened quickly. The bug lived in the waiting room between "the browser is already sending audio" and "the ML service is ready."&lt;/p&gt;

&lt;p&gt;That is exactly where production bugs like to hide: not in the main algorithm, but in the glue code that assumes the next component will be ready soon.&lt;/p&gt;

&lt;p&gt;The fix was not a bigger abstraction. It was a boundary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;audio may wait briefly, but it may not wait forever.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one rule turns an unbounded failure into a controlled one.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Your AI App Does Not Need Ten Model Clients. It Needs a Gateway Workflow.</title>
      <dc:creator>Alex Shev</dc:creator>
      <pubDate>Tue, 14 Jul 2026 23:44:12 +0000</pubDate>
      <link>https://dev.to/alexshev/your-ai-app-does-not-need-ten-model-clients-it-needs-a-gateway-workflow-15il</link>
      <guid>https://dev.to/alexshev/your-ai-app-does-not-need-ten-model-clients-it-needs-a-gateway-workflow-15il</guid>
      <description>&lt;p&gt;Most AI apps start with one provider.&lt;/p&gt;

&lt;p&gt;That is usually fine.&lt;/p&gt;

&lt;p&gt;You add an OpenAI SDK call, pick a model, ship the feature, and move on.&lt;/p&gt;

&lt;p&gt;Then the product grows.&lt;/p&gt;

&lt;p&gt;One workflow needs a cheaper model. Another needs a stronger reasoning model. Another needs image or video generation. A customer wants data routed through a specific provider. A fallback model becomes necessary because the primary one is rate-limited during a launch.&lt;/p&gt;

&lt;p&gt;Suddenly, "just call the model" is not really the architecture anymore.&lt;/p&gt;

&lt;p&gt;The real problem is routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: provider logic spread across the app
&lt;/h2&gt;

&lt;p&gt;The messy version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support_summary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="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;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deep_research&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="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;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;imageProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works for a while.&lt;/p&gt;

&lt;p&gt;But as soon as the team needs fallbacks, model swaps, cost controls, or media endpoints, provider decisions start leaking into product code.&lt;/p&gt;

&lt;p&gt;Now the app has two problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;product logic&lt;/li&gt;
&lt;li&gt;model routing logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those should not be the same layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gateway is boring in the best way
&lt;/h2&gt;

&lt;p&gt;This is why I like the &lt;code&gt;routerbase-gateway&lt;/code&gt; pattern on Terminal Skills.&lt;/p&gt;

&lt;p&gt;The useful idea is not "one more AI provider."&lt;/p&gt;

&lt;p&gt;The useful idea is putting OpenAI-compatible calls, model IDs, fallback choices, streaming behavior, tool calling, JSON mode, and media generation behind one gateway layer.&lt;/p&gt;

&lt;p&gt;Instead of teaching every part of the app how to talk to every model provider, the application talks to one interface.&lt;/p&gt;

&lt;p&gt;The routing layer decides what happens behind it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&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;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;OpenAI&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;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;ROUTERBASE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.routerbase.com/v1&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;result&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;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fast-support-summary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Summarize the customer issue clearly.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transcript&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;The application code stays boring.&lt;/p&gt;

&lt;p&gt;The routing policy can change without rewriting every feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for agents
&lt;/h2&gt;

&lt;p&gt;An AI coding agent can wire up raw API calls.&lt;/p&gt;

&lt;p&gt;That is not the hard part.&lt;/p&gt;

&lt;p&gt;The hard part is getting the agent to make the same architectural decision the team would make:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep provider-specific logic out of product code&lt;/li&gt;
&lt;li&gt;use environment variables consistently&lt;/li&gt;
&lt;li&gt;keep model names configurable&lt;/li&gt;
&lt;li&gt;support fallbacks without scattering conditionals everywhere&lt;/li&gt;
&lt;li&gt;preserve streaming and JSON-mode behavior where the feature needs it&lt;/li&gt;
&lt;li&gt;avoid logging sensitive prompts or keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is exactly the kind of workflow that belongs in a skill.&lt;/p&gt;

&lt;p&gt;Not because the commands are complicated.&lt;/p&gt;

&lt;p&gt;Because the judgment should be repeatable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The skill version of the task
&lt;/h2&gt;

&lt;p&gt;Without a skill, the prompt tends to be vague:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Migrate this app to use RouterBase.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That leaves too many decisions open.&lt;/p&gt;

&lt;p&gt;With a workflow-oriented skill, the agent has a narrower job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx terminal-skills &lt;span class="nb"&gt;install &lt;/span&gt;routerbase-gateway &lt;span class="nt"&gt;--agent&lt;/span&gt; codex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the agent can follow a safer pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find existing OpenAI-compatible client calls.&lt;/li&gt;
&lt;li&gt;Move provider configuration into environment variables.&lt;/li&gt;
&lt;li&gt;Replace direct provider clients with the gateway base URL.&lt;/li&gt;
&lt;li&gt;Keep model IDs outside hard-coded feature logic where possible.&lt;/li&gt;
&lt;li&gt;Preserve streaming, tool calling, and structured-output behavior.&lt;/li&gt;
&lt;li&gt;Add a small verification path before claiming the migration worked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is much closer to production work than "switch the API key."&lt;/p&gt;

&lt;h2&gt;
  
  
  The real win is change management
&lt;/h2&gt;

&lt;p&gt;Model routing is not just an infra preference.&lt;/p&gt;

&lt;p&gt;It changes how product teams can operate.&lt;/p&gt;

&lt;p&gt;When the routing layer is explicit, the team can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;test cheaper models on low-risk tasks&lt;/li&gt;
&lt;li&gt;keep stronger models for high-value workflows&lt;/li&gt;
&lt;li&gt;add a fallback route for outages&lt;/li&gt;
&lt;li&gt;move media generation behind the same operational pattern&lt;/li&gt;
&lt;li&gt;compare providers without rewriting the feature&lt;/li&gt;
&lt;li&gt;keep provider churn away from product code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application becomes less attached to one vendor.&lt;/p&gt;

&lt;p&gt;The team becomes more explicit about which model is used for which job.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is where skills are useful
&lt;/h2&gt;

&lt;p&gt;Most teams do not need an agent that merely knows a gateway exists.&lt;/p&gt;

&lt;p&gt;They need an agent that knows how to migrate safely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;inspect the codebase first&lt;/li&gt;
&lt;li&gt;identify provider-specific assumptions&lt;/li&gt;
&lt;li&gt;make the smallest useful change&lt;/li&gt;
&lt;li&gt;avoid leaking secrets&lt;/li&gt;
&lt;li&gt;preserve existing behavior&lt;/li&gt;
&lt;li&gt;verify the path after the edit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the difference between a tool and a workflow.&lt;/p&gt;

&lt;p&gt;The tool is the gateway.&lt;/p&gt;

&lt;p&gt;The workflow is how you introduce it without making the codebase harder to reason about.&lt;/p&gt;

&lt;p&gt;Source workflow:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://terminalskills.io/use-cases/route-ai-model-requests-through-routerbase" rel="noopener noreferrer"&gt;https://terminalskills.io/use-cases/route-ai-model-requests-through-routerbase&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Related skill:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://terminalskills.io/skills/routerbase-gateway" rel="noopener noreferrer"&gt;https://terminalskills.io/skills/routerbase-gateway&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;p&gt;If your AI app is starting to juggle providers, models, fallbacks, and media endpoints, do not scatter that logic through the product.&lt;/p&gt;

&lt;p&gt;Put it behind a gateway.&lt;/p&gt;

&lt;p&gt;Then teach the agent the migration workflow.&lt;/p&gt;

</description>
      <category>architecture</category>
    </item>
  </channel>
</rss>
