Small B&Bs in Hengchun don’t win on budget against OTAs — they can’t. Commission rates are higher than most people realize, and behind those rates sit professional marketing teams, brand trust built over years, and ad spend that would dwarf anything a 10-room property could justify. The only viable strategy is precision: every bid concentrated on terms that actually convert to direct bookings, zero allocation to anything that doesn’t.
Precision has a silent failure mode. Keyword lists accumulate. Non-converting terms linger alongside performers, budget disperses across a growing long tail, effective bids get diluted, and average CPA climbs slowly enough that months of spend data skew before anyone notices. The governance layer is what holds the strategy together. Without it, the precision strategy erodes quietly — there’s no alarm, just steadily worsening performance that arrives too late to diagnose cleanly.
This is a keyword governance system for the Google Ads pipelines I run for B&B operators in Hengchun. It pulls enabled keywords from the live account weekly, syncs an inventory, filters candidates by a protection window, passes them to Claude for evaluation, and — after a human review step — writes decisions back to the account. What makes it work isn’t the LLM. It’s where state lives.
The architecture decision is about who acts
I had a Postgres schema half-mapped when I stopped and asked: when this system generates a recommendation at 2 AM, who queries the state? Me. When a client disagrees with a removal, who translates the database record into a readable explanation? Also me. The admin panel would exist for me to intermediate — not for her to act.
The spreadsheet she already opens every morning — cross-referencing her bookings against the OTA dashboard — would be the database.
The operational core is Keyword_Inventory. Each row is a keyword. Seven columns track the full lifecycle:
| Column | Description |
|---|---|
CriterionID | Google Ads criterion ID |
Keyword | The search term |
AdGroupID | The ad group it belongs to |
Status | Active or Removed |
Date_Added | When it was added (YYYY-MM-DD) |
Date_Removed | When it was removed; empty if still active |
Reason | Plain-language explanation for the last state change |
The Reason column is load-bearing. Every removal gets written in plain language — “30 days, 3 clicks, 0 conversions” or “overlaps with a higher-performing synonym.” She reads that on her phone and already knows what happened. No intermediary, no support message, no me translating a query result. More importantly: if she disagrees with a removal, she flips Status to Active, clears Date_Removed, and the next run treats the keyword as live. She’s acting on primary source data, not my summary of it. That changes who actually owns the decisions — not just who can see them.
Two tabs, two responsibilities
Each account uses two tabs. Keyword_Inventory is the state machine for keyword lifecycle. A second tab is the control ledger — it connects analysis to execution and tracks what’s pending human approval.
This structure maps to a two-phase pipeline. The analyzer runs on a weekly cron (Monday morning, Asia/Taipei) via GitHub Actions, and also accepts manual triggers. Each run pulls enabled keywords from Google Ads, syncs the inventory, applies the 14-day protection filter, passes eligible keywords with their performance metrics to Claude, and builds an operation payload. Claude returns a structured response: { mvp, monitor, add_suggestions, remove_suggestions } — one best performer, a watchlist, new terms to add, and underperformers to cut.
The analyzer then generates a task ID (TASK-XXXX format), appends a row to the tasks tab with status PENDING and the serialized operation payload, and sends an HTML email with a link to the executor workflow and the task ID visible in plain text. The pipeline stops there.
To execute, I take the task ID from the email, go to GitHub Actions, and manually trigger the executor workflow — specifying the task ID and an action (add or remove). The executor reads the task row, applies operations to Google Ads, updates inventory with the results (new keywords get Date_Added, removed keywords get Date_Removed and a Reason), and marks the task DONE.
This isn’t “I review it before running.” It’s a hard architectural constraint. Execution cannot happen without explicit human input — there’s no scheduled execution, no automated apply, no way to accidentally apply a batch. You have to produce the task ID from the email and supply it manually. The two-step structure makes human sign-off impossible to skip.
The 14-day rule, enforced twice
Keywords need data before evaluation is meaningful. Three days of impressions is noise. The protection window requires at least 14 days of exposure before a keyword is eligible for removal.
This rule is written into the LLM system prompt. It’s also enforced as a hard filter in code, applied to Claude’s output before anything reaches the executor:
def is_protected(date_added: str, today: date) -> bool:
parsed = _parse_date(date_added)
if parsed is None:
return True # treat unparseable as protected
return (today - parsed) < timedelta(days=PROTECTION_DAYS)
Two enforcement points because LLMs occasionally ignore rules under data pressure. The prompt tells Claude what the rule is. The code makes violation impossible regardless of what Claude returns. If Date_Added can’t be parsed at all, the keyword defaults to protected — the conservative failure mode. The same evaluation cycle that tells the LLM about the rule also checks the LLM’s output for compliance with it.
The LLM system prompt includes B&B-specific guardrails beyond the protection window: what each property actually sells (BBQ, KTV, paddle pools, walking distance to the night market), what it cannot honestly claim (sea views, mountain scenery, large pools), and which keyword categories are disqualified regardless of click data. Setting those guardrails once per property — rather than re-evaluating manually each week — is the part that makes this economically viable. A PPC specialist encoding that judgment account by account is a recurring cost these properties couldn’t justify.
From four containers to zero
The system this replaced ran on n8n across four always-on containers: main process, worker, Redis, and Postgres. The Postgres instance existed only for n8n’s AI chat memory — not for any business logic. Total overhead: roughly $10/month, running 24 hours a day, utilized for a few minutes per week.
The replacement runs entirely on GitHub Actions free tier. No always-on containers, no persistent database — all state in the spreadsheet. LLM cost on the Anthropic API runs to pennies per month across four weekly runs for two accounts. The static system prompt, around 1,500 tokens of rules and property-specific guardrails, uses prompt caching on the Anthropic API — when both accounts run in the same weekly job, the second call hits the cache.
Moving the approval gate from an email webhook button (which required a live Railway webhook server) to a workflow_dispatch trigger with manual task ID input removed the last always-on piece of infrastructure. The trade-off is a slightly less convenient UX: one click became a manual copy-paste and a GitHub UI visit. For a tool used by one person once a week, that’s acceptable — and the same executor workflow design can later accept a trigger from a Cloudflare Worker or similar proxy without architectural change.
The concurrency question
Using a spreadsheet as a state machine raises a legitimate concern: concurrent writes. Two runs overlap, both read a row as Active, both decide to remove it, the second write silently overwrites whatever the first already executed against the live account.
File locks, a lease column, a lightweight Redis flag — each option pushes toward a complexity tier that undermines the point. If maintaining this tool requires its own ops burden, the cost calculus breaks.
The actual solution: avoid the conditions. The weekly cron and the manual trigger can theoretically overlap; concurrency: cancel-in-progress in the analyzer workflow handles that edge. The executor is never scheduled — it runs only on deliberate manual trigger, one action at a time. At weekly cadence across a handful of campaigns per property, concurrent execution is theoretical rather than practical.
The system has run across two properties without a concurrency incident. The moment it actually worked wasn’t when the first automated evaluation ran correctly. It was the first time a client corrected a removal decision herself and didn’t bother to tell me.