The math on Google Ads for a small B&B in Hengchun competing against OTAs is simple enough to work out on a napkin: if annual ad spend comes in below the commission surrendered on the same number of bookings, Google Ads wins. OTAs dominate search for compounding reasons — budgets that dwarf any single property’s ceiling, brand recognition built over years of consumer exposure, and full-time digital marketing teams running their accounts. Commission rates are higher than most operators realize. But high-intent search exists: someone who already knows they want Hengchun and is choosing between booking direct or through a platform. Google Ads is that interception point.
The catch is that Google Ads is a weekly management job. Monthly reporting is a post-mortem: by the time an owner reads a month-end summary, three weeks of budget has already moved, and the keyword adjustment I’d have made in week two is irrelevant to week four. The gap bnb-ads-manager closes is making weekly account-level analysis economically viable for clients at a scale where no agency would take the engagement.
Four services running 24/7 for a weekly job
The first version ran on n8n, hosted on Railway. Four long-lived services: n8n Main, n8n Worker, Redis, and PostgreSQL. The trigger was IMAP polling — a weekly email arriving in a monitored inbox fired the pipeline. Google Ads API pulled keyword performance; a Keyword_Inventory tab in Google Sheets provided the state machine; an AI agent (OpenAI via LangChain) analyzed the combined data; a task row landed in Sheets with the operation payload; an HTML review email went out with an embedded approval button. The owner clicked the button in Gmail, a Railway webhook fired, and the Ads mutation ran.
It worked. It also kept four services running continuously for a tool that does meaningful work four times a month.
Before writing a single line of Python, I audited what each Railway service actually did. The PostgreSQL finding changed the entire infrastructure decision: the database existed solely for LangChain’s AI chat memory. It had zero role in the actual business logic. The keyword inventory, task history, and operation payloads all lived in Sheets from day one. Removing Railway meant removing idle cost with no functional consequence — not a trade-off, just a correction.
The Python rewrite moves both pipeline halves into GitHub Actions. The analyzer runs on a cron (0 1 * * 1 UTC — Monday 09:00 Asia/Taipei) and writes a task row to Sheets with the LLM output and operation payload. The executor is a manual workflow_dispatch workflow: the review email links to the GitHub Actions UI with a task ID the owner inputs to trigger the Ads mutation. One more step than the Railway webhook button. An acceptable trade-off for zero always-on containers and infrastructure cost that rounds to nothing.
A single OAuth client now serves Google Ads, Sheets, and Gmail — one refresh token, one credential surface.
The 14-day rule needs two enforcement points
The first production oscillation appeared early. The system recommended removing a keyword; I held it; the following week, the system recommended adding the same keyword back. Three days of impression data is noise. Acting on it wastes budget and likely degrades Quality Score.
The intuitive fix is to tighten the prompt. The problem with that approach is that prompt-level rules are advisory. Under sufficient data pressure — when a specific keyword’s numbers look compelling — the model will follow the data. The constraint needs to live outside the model’s reasoning:
def is_protected(date_added: str, today: date) -> bool:
parsed = _parse_date(date_added)
if parsed is None:
return True # unparseable date → treat as protected
return (today - parsed) < timedelta(days=PROTECTION_DAYS)
filter_protected_removals() in decisions.py splits the LLM’s remove_suggestions into two lists: allowed and blocked. Both lists appear in the review email. The owner sees what Claude wanted to remove and why it didn’t execute — a silent filter made visible. The system prompt encodes the 14-day rule; the execution layer enforces it again regardless of what the model outputs. One catches what the other misses.
The same function has a second defense: if a criterionId from the LLM’s output doesn’t exist in the local inventory, it’s discarded. The model occasionally hallucinates IDs when keyword data is sparse. Matching against the inventory snapshot before building the mutation payload catches these before they reach the API.
Context is the difference between generic and useful
Feeding raw keyword CSV to the model produces recommendations any B&B could receive — which means they’re optimized for no B&B in particular. Each account has a topic_facts block injected into the system prompt, encoding what the property can and cannot truthfully claim:
🛑【民宿事實與禁忌】
1. 玩水:❌禁大型泳池/深水。✅強調:陽台戲水池、兒童戲水池、泡腳池 (安全/水淺)。
2. 景觀:❌禁海景/山景/View。✅強調:市區便利、走路到全聯/夜市。
3. 賣點:烤肉 BBQ、KTV、麻將、親子友善。
The system prompt runs in Chinese. Not because the analysis couldn’t work in English, but because the reasoning needs to stay coherent with what the client knows about their property. “Paddle pool” and “walkable to the night market” carry connotation in Mandarin that translation would blunt. Having the model reason in English and output in Chinese adds a translation layer between the property facts and the recommendations.
With this context block present, the model can reason about seasonality and keyword lifecycle together. “This keyword is low-volume now, but peak season returns in six weeks — hold rather than remove” is a conclusion that requires knowing both the search trend and the property’s actual season pattern. Without the context block, the model has only the CSV.
What the output schema revealed about owner psychology
The initial JSON schema had three fields: monitor, add_suggestions, remove_suggestions. After the first few production weeks, it became clear that owners reading a pure action list responded defensively — what broke, how much did it cost. The framing triggered a loss-aversion reaction before the owner had read a single recommendation.
Adding mvp — the keyword currently earning its spend — changed the reception of the entire email. An explicit “here’s what’s working” anchor before the action items moved owners from defensive to evaluative. The schema change was one field; the effect on owner engagement was not marginal.
The MVP eligibility criteria went into the system prompt: a keyword must have at least 14 days of data and either a CTR above 1% or a conversion. Without the data floor, the model would nominate a three-day-old keyword with two clicks as the week’s standout performer.
Provider abstraction for comparison without code changes
The LLM client sits behind a Protocol interface:
class LLMClient(Protocol):
def analyze(self, system_prompt: str, user_payload: dict[str, Any]) -> dict[str, Any]: ...
def build_llm_client(cfg: Config) -> LLMClient:
if cfg.llm_provider == "anthropic":
return AnthropicLLMClient(cfg.anthropic_api_key, cfg.llm_model)
if cfg.llm_provider == "openai":
return OpenAILLMClient(cfg.openai_api_key, cfg.llm_model)
raise RuntimeError(f"unknown LLM_PROVIDER: {cfg.llm_provider}")
The default is Claude, configured via environment variables. The OpenAI implementation exists because the original n8n system ran on OpenAI. Preserving the same interface means the n8n system prompt is directly comparable against Claude’s output with no code change — one environment variable swap, same prompt, side-by-side results.
The static system prompt runs approximately 1,500 tokens. With both accounts analyzed in the same GitHub Actions job within minutes of each other, the second call hits the prompt cache rather than re-uploading the same token block. Not the primary cost driver at four weekly runs, but a detail worth building in from the start.
What I underestimated
Producing consistent structured JSON from the model under sparse data conditions — new campaigns, keywords with four days of impressions, ad groups with no conversion history yet — required more output validation and prompt iteration than I’d planned for. The model’s behavior when input is thin is harder to characterize than its behavior when data is complete. “What does the model do when it has nothing to say about MVP?” turned out to be a prompt design question with a non-obvious answer.
The other underestimated cost was the email itself. Getting two B&B owners in Hengchun to open a review email every Monday, read past the first two lines, and take a specific action on a specific recommendation is an interface design problem. The structured JSON output and the Sheets audit trail are infrastructure. The HTML email — which decides what to surface first, how much reasoning to show, what the call-to-action looks like — is the product. That’s what weekly professional ads management actually provides at this scale: not just the analysis, but a communication that makes the analysis legible enough to act on.