[CODE]

The campaign that passed visual review — and pointed every URL at a domain that doesn't exist

Automated Google Ads setup for a new Hengchun B&B produced a structurally complete campaign with two independent bugs: every URL built from a wrong domain in a config file, and four sitelinks linking to pages the LLM invented. Both surfaced on the first live run.

7 min read AI-generated
google-ads typescript automation bnb

The Google Ads engine I run for B&B operators in Hengchun manages five properties across three operators. None of them have marketing staff. None use booking software that generates real conversion events. The optimization signal the engine works from is behavioral: phone clicks, LINE contact buttons, booking page entries — captured through GA4 and fed to Google as the closest available proxy for reservation intent. The economics hold because the per-property management cost is low enough that operators can justify it. These are small, family-run properties competing against OTAs whose commission rates are higher than most clients realize and whose ad budgets dwarf anything these operators could field independently. Direct booking is the margin. The engine’s job is to make capturing some of it affordable.

Onboarding cost is part of that arithmetic. Each new property joins the engine with an existing website and no campaign history. Without an automated setup tool, each new property means a full manual session: read the site, identify the booking-intent pages, write copy grounded in what the property actually offers, build the account structure from scratch. Manageable once. Not manageable at volume.

ads-setup-campaigns.ts handles this. It reads two inputs: site-config.yaml, which holds the live domain and account identifiers, and business-context.md, a structured document with selling points, room types, and booking signals specific to the property. Both go to Gemini 2.5 Pro via the @google/genai SDK with a JSON schema enforcing structured output — 15 headlines, 5 descriptions, 15–25 keywords across exact, phrase, and broad match types, and 6 sitelinks. The script then calls the Google Ads API to create two new PAUSED campaigns — Performance Max and Search — with the generated assets linked. Nothing goes live without a human enabling it. The assumption was that review would catch copy issues: tone, CJK character limit violations, relevance gaps. Structural validity was assumed.

The first live run produced a campaign that looked correct everywhere a visual check lands.

Two failures on one run

Every URL in the campaign pointed at a domain that doesn’t exist. The site-config.yaml for this property had site_domain set to the property’s GitHub repo slug — no hyphens, formatted as a single compound word. The actual live domain uses a different string: hyphenated, with different word boundaries. Both are recognizable variants of the property name. One is what GitHub generates from the repo name; the other is what was registered at the domain registrar. When writing the config, the repo slug was typed instead of the live domain.

ads-setup-campaigns.ts derives baseUrl from site_domain and assembles every URL in the output from it. All six sitelink finalUrl values for both the PMax and Search campaigns, the Search RSA final_urls, the PMax asset group final_urls — every URL in the account is built from that single field. One wrong string in one YAML key propagated to every destination URL written to the Google Ads account.

This doesn’t surface on a visual review of the management interface. The interface shows campaign structure, copy, and status. It doesn’t show whether the destination URLs resolve. The wrong domain looked like a plausible domain for this property — it was a recognizable compression of the property name. The campaign hierarchy was correct. The ad copy was correct. The budget split was correct. The keyword structure was correct. The only thing wrong was the domain, and that wrong value appeared in every URL.

That was one bug. The same run produced a second, independent failure: four of the six sitelinks had destinations that don’t exist at any domain. The LLM generated paths that fit a generic accommodation site structure — a facilities page, a contact page, a location page, a reviews page. The actual site routes those concepts to different paths. The generated paths were plausible, not verified. The LLM had no constraint on which paths it could generate, so it generated paths that match what a standard accommodation site looks like, not paths that exist on this site.

Two independent bugs. One from a human-typed config value, one from unconstrained LLM generation. Both caught on the same first live run.

Repair in order

Domain first. ads-fix-domain.ts takes OLD_DOMAIN and NEW_DOMAIN as environment variables and runs against the account. Sitelink asset final_urls are updatable directly through the Google Ads mutations API. RSA final_urls are not — the API does not allow updating final_urls on an existing responsive search ad. The only path is to remove the RSA and recreate it in the same ad group with the corrected URL, preserving the existing headlines and descriptions. The script handles both: update all sitelink assets in bulk, then for each affected RSA, remove it and create a new one with the corrected URL. Sitelinks fixed, RSA replaced.

Paths second. ads-fix-sitelinks.ts reads a mapping JSON file — ads-sitelink-fix.json — that specifies the wrong URL for each affected sitelink and the correct replacement:

  • facilities path → actual intro page path
  • contact path → actual booking page path
  • location path → actual access page path
  • reviews path → homepage

Four sitelinks updated through the same mutations API. The account was new enough that the traffic through those dead links was minimal — the learning window hadn’t yet built on corrupted signal.

Fixing the root causes

The domain bug has an operational fix, not a structural one. site-config.yaml is human-authored. The site_domain field is the single value from which the tool derives every URL it will write to a live ad account. It isn’t validated against a DNS record before setup runs. The right behavior at config-writing time is to confirm the domain resolves and cross-check it against the live site before running setup. The value is static after setup — the entry point is the one place to get it right.

The path hallucination has a structural fix. ads-setup-campaigns.ts now reads site-state.json — a pre-generated snapshot of the site’s actual page inventory, produced by the site-snapshot job — and injects the available page paths into the LLM prompt. The Gemini call now receives an explicit constraint: sitelink finalUrl values must be chosen from this list of verified paths. The LLM generates creative sitelink text and descriptions freely; it cannot invent destination URLs.

This changed the onboarding dependency graph. Previously the setup script needed only business-context.md and site-config.yaml. Now it also requires site-state.json. The site-snapshot job must run before campaign setup. New property onboarding has an ordering requirement it didn’t have before.

Where validation was missing

Both failures share the same structural property: the tool accepted an input without verifying it against external reality.

The domain value was human-typed and entered an unchecked field that controlled every URL the tool would generate. The page paths were LLM-generated and entered an unconstrained generation step with no reference to the real page inventory. The campaign architecture, copy quality, character limit compliance, keyword structure — all of that was correct. The errors lived at two specific boundaries: between human-typed config and tool execution, and between LLM generation and real-world site state.

Both boundaries now have controls. A wrong domain in site-config.yaml becomes visible before campaign setup runs, because site-state.json is generated from a successful site crawl — which requires the domain to resolve. Wrong page paths are structurally impossible because the LLM now selects from paths that site-state.json confirms exist.

The failure was low-cost because the account was new. The same bugs in a mature account with weeks of signal would have meant conversion data built on clicks routing to dead destinations — the optimization model learning from bad input from the start. That’s a harder problem to undo than replacing URLs and recreating an RSA.

The next property onboarded through the tool required site-state.json to be present, drew sitelink paths from verified inventory, and produced no domain issues and no 404 sitelinks. The first live run found both failure modes at the lowest possible cost.