The original system ran four containers on Railway — n8n Main, n8n Worker, Redis, and PostgreSQL — for a pipeline that executed once a week. Monthly cost: around $10. Monthly runtime: under 15 minutes across four runs. Utilization somewhere near zero.
That’s the infrastructure problem. The product problem was separate: the n8n workflow was built for one client, with the Google Ads customer ID, ad group ID, and spreadsheet document ID hardcoded directly in the workflow JSON. When the second B&B operator came in, the only path forward was duplicating the entire workflow — an independent n8n instance, independent secrets, independent maintenance surface per account. A third account meant a third copy.
The rebuild addressed both at once.
Why the economics make this worth building
OTA commissions on every reservation are higher than most operators realize. For a small property in Hengchun, recovering even a fraction of that through direct bookings is real margin — not a rounding error. But managing Google Ads campaigns competently requires keyword analysis, match-type strategy, and ongoing cleanup. That workload doesn’t scale down to fit an owner managing six rooms; it stays the same size regardless of property scale. Professional campaign management was simply out of reach for operators at this size before AI tooling made the repeated analytical work cheap enough to automate.
The n8n system was the first answer. It analyzed keywords through OpenAI, sent an approval email, and applied mutations when the owner clicked a webhook button. One account. Hardcoded throughout.
The rebuilt system runs the same analysis against multiple accounts in a loop, with per-account configuration in env vars and account-specific LLM context injected per run.
What multi-account support actually means in code
Each B&B is an Account dataclass: customer ID, ad group ID, the Google Sheets tabs it owns, an email subject prefix, and a topic_facts string that gets injected into the LLM system prompt. Accounts are registered at startup via env vars — if ADS_<CLIENT>_CUSTOMER_ID and ADS_<CLIENT>_AD_GROUP_ID are present, that account is in the run. If not, it’s silently skipped. Adding a third B&B means setting two env vars. No code changes.
The topic_facts field is where the system earns its keep at the account level. One client property has specific guardrails baked in: no sea-view or mountain-view claims, no large-pool references — the property doesn’t have these, and an AI hallucination in ad copy is a refund conversation, not just a mistake. The analyzer loop runs each account independently; a failure in one account logs the exception and continues — one property going down doesn’t stop the others from receiving their weekly analysis.
The approval gate
The LLM returns structured JSON per account: mvp (the single best-performing keyword), monitor (watchlist candidates), add_suggestions, and remove_suggestions. That output gets rendered into an HTML email with a generated task ID (TASK-XXXX) and a link to the executor workflow in GitHub Actions.
Nothing touches a live account automatically. The review step is mandatory by design, not convention: the analyzer only writes to Google Sheets and sends email. A separate executor.yml workflow handles mutations, and it requires explicit inputs — task ID and action (add or remove) — to run. Wayne reviews the email, decides which direction to apply, navigates to GitHub Actions, pastes the task ID, and triggers the executor. The executor reads the task row from Google Sheets, pulls the corresponding operations, and mutates the account.
This replaced n8n’s webhook button — a single click to a Railway-hosted endpoint. The new mechanism has worse UX by exactly that delta: one step instead of one click. The trade-off is that the webhook button required an always-on service to receive and route the request, and that was the last piece of always-on infrastructure. Replacing it with workflow_dispatch eliminated the final dependency.
The rule the LLM can’t be fully trusted to keep
New keywords need at least 14 days of impression data before a removal decision is meaningful. The LLM system prompt encodes this as an explicit rule. It also gets violated — not consistently, but under data pressure (a batch of new keywords with uniformly poor-looking early numbers), the model occasionally recommends removal before the window closes.
The protection is enforced in two places. The LLM prompt states it; decisions.py enforces it as a hard filter. The analyzer passes the LLM’s remove_suggestions through filter_protected_removals, which looks up each suggested criterion ID in the inventory, checks the date_added field, and strips any suggestion younger than 14 days before the payload reaches the task row. Keywords with unparseable dates are treated as protected by default. The belt-and-braces framing is explicit in the code comments: treating the LLM as the only enforcement point is fragile.
The email shows both lists — what the LLM recommended and what was blocked by the protection window — so the operator sees the model’s reasoning without the blocked suggestions ever reaching execution.
What the migration uncovered
The n8n workflow export contained the Google Ads developer token in plaintext alongside every other hardcoded constant. This is a structural property of no-code platforms that serialize credentials into exportable workflow JSON: the export format doesn’t distinguish between configuration and secrets. The migration required rotating the developer token before the Python version could be wired to the live account.
The Postgres database was initially retained in the migration plan — the cheapest Railway component at roughly $2/month, and assumed to hold business-critical state. Reverse-engineering the n8n workflow JSON revealed it was used exclusively for LangChain’s memoryPostgresChat: AI cross-run context that the weekly analysis doesn’t need. Each run analyzes the current keyword inventory independently; no memory of prior runs is load-bearing. Postgres was dropped entirely. The state store is now Google Sheets — the same spreadsheet the original n8n workflow was already writing to.
The migration also surfaced the cost comparison in exact terms: four Railway containers at ~$10/month versus GitHub Actions cron at effectively $0 for four runs per month, each running under 15 minutes. The savings aren’t the point of the work, but they’re a concrete measure of how much idle infrastructure a once-weekly task can accumulate when the initial design never anticipated multiple accounts.
What’s still in progress
The analyzer and executor pipelines are scaffolded with typed stubs and TODO markers throughout; the LLM analysis core and client builders (Anthropic, Google Ads, Google Sheets, Gmail) work independently. What remains is wiring them in the full pipeline and smoke-testing against the live account after the rotated credentials are in place. The second B&B’s customer ID and ad group ID aren’t confirmed yet — the topic_facts field holds a placeholder that instructs the LLM to avoid inventing property details until the operator provides them.
A third account is a config change. The architecture doesn’t need to change to support it.