I Automated GA4 with YAML. Now It Flags What to Check in SEO
A CLI + MCP server that configures GA4 and Search Console from YAML, and flags what to check in your SEO by impact. Deterministic: you make the calls. →
Software engineer · Málaga

On this page
Short answer: GA4 Manager is my open-source Go CLI and MCP server. One YAML file sets up GA4 and Search Console, then it runs deterministic Search Console checks: CTR gaps ranked by the clicks they cost you, cannibalization, index health, hreflang and URL hygiene. No model guesses at SEO: the checks are API queries, and what to change is my call.
I started building this so I’d stop losing 40 minutes clicking through the GA4 UI every time I spun up a project. What I didn’t expect was where it ended up: now the tool doesn’t just configure analytics, it flags what to look at in my SEO, ranked by how many clicks it’s costing me. It doesn’t decide for me. It puts the data in front of me, prioritized, and I pick what to touch.
That distinction is the whole point, and I’ll come back to it. This is the story of how it happened, what it does now, and what came out when I pointed the full suite at this very blog, including the moment the tool got it wrong (and why that gave me more confidence, not less), and the second run four months later that was a lot less flattering.
The original problem: clicking like a monkey
I have several side projects. This portfolio, WealthSim, and a few smaller ones. Each needs analytics, and every time I spun up a new one the ritual was the same: create the GA4 property (easy), then click. Conversions, ten clicks each. Dimensions, more clicks. Metrics, more. Data retention, where was that option again? Enhanced measurement, another menu.
Thirty or forty minutes of repetitive clicking. And the worst part: if I wanted the same config on another project, I’d repeat it all from scratch. GA4 has no “export configuration.” No way to copy setup between properties.
After configuring the third project in one week, I thought what you’re thinking right now: this is exactly the kind of work that should be automated.
The premise: analytics as code
I define my infra as code. My CI/CD as code. Why not my analytics?
That was the whole idea. A YAML file that describes how I want GA4, and one command to apply it:
project:
name: "My Project"
ga4:
property_id: "123456789"
tier: standard
conversions:
- name: newsletter_signup
counting_method: ONCE_PER_SESSION
dimensions:
- parameter: article_category
scope: EVENT
ga4 setup --config my-project.yaml
Boom. Config applied. No clicks, no typos, versioned in git next to your code. From 40 minutes to under one. That was GA4 Manager v1.
The turn: it stopped showing me data and started pointing at what to check
This is where the tool got genuinely interesting.
The first half of its life was configuration: apply this YAML, create these conversions, submit this sitemap. Useful, but passive. The second half, the part that actually changed how I work, is diagnostics. Back in June, on v2.3.1, the MCP server exposed 20 tools (40 as of v3.4.0), and the newest ones don’t show you data: they tell you where to start.
The one I use most is gsc_opportunities. It finds queries where you already rank on page 1-2 (position 5-20) but with a CTR below the median for that bucket, and sorts them by potential_clicks: the extra monthly clicks you’d gain just by hitting the median CTR. Biggest wins first.
On this blog? It found exactly one. And at position ~18, page two, where a title tweak won’t move it. The tool didn’t inflate it: it told me the truth, that there was no gold mine here. On a site with real traffic, this is the tool that buys you coffee; on a small one, it confirms there are no shortcuts. Both answers work for me.
The other three in the new group follow the same “what first” philosophy:
gsc_cannibalization: queries where two or more of your pages split impressions and eat each other’s authority; it points at the canonical candidate.gsc_ctr_anomaly: (query, page) pairs where position barely moved but CTR collapsed. Translation: your snippet stopped converting. Rewrite it.gsc_health: an indexing report that diffs each URL’s state against the prior snapshot, so an accidentalnoindexsurfaces in days, not weeks. Silent when everything’s green. Built for a cron.
And here’s what, for me, separates this from the pile: none of it is the AI guessing at SEO. These are deterministic queries against the Search Console API (a Go CLI, structured output). The LLM only orchestrates the calls and summarizes the result; the interpretation and the “yes-this, no-that” are mine. If vibe coding gives you hives (it does me), that’s exactly the line that matters: the tool surfaces, you judge.
What happened when I pointed it at this blog
This blog is one of my side projects and runs on a site that uses GA4 Manager via MCP, so in June I sat down with Claude and pointed the full suite at oscargallegoruiz.com. I expected to find disasters. Reality was more boring, and more interesting than it sounds.
43 of 43 pages indexed. gsc_ctr_anomaly: empty. seo_page_audit on the top pages: clean, zero errors. In short: the site was healthy. And a tool telling you “everything’s fine” instead of inventing a problem to justify itself is a signal I trust.
But there was a scare. gsc_sitemaps_list returned an empty list, as if I’d never submitted the sitemap. The easy reflex would’ve been to resubmit it and move on. Instead I verified with gsc_sitemaps_get… and there it was, submitted and downloaded by Google, no problem. The empty list was a bug in my own tool, not an SEO failure.
And that’s the moral I care about, the one that ties back to everything above: I didn’t trust the output blindly. If I’d acted on the false positive, I’d have “fixed” something that wasn’t broken. Verifying saved me the embarrassment, and along the way, the tool I wrote to catch problems caught a bug in itself. I fixed it. That’s the healthy relationship with a tool like this: it gives you leads, you confirm them. The one I never trust is the one that promises you don’t need to confirm anything.
Four months later, the blog was visible, churning and uncited
In October I ran it again, with everything that’s now in v3.4.0, including a batch of DataForSEO tools. June’s verdict was “healthy”. October’s was less flattering, and it stung more than the sitemap scare did:
site_growth_brief(28 days, Sep 6 to Oct 3): 6,012 impressions, 20 clicks, 0.33% CTR at an average position of 7.6. The blog shows up; almost nobody clicks. Most impressions on the top pages came from long queries ending in things like “answer with sources”. GSC doesn’t label them, but that shape looks to me like AI search fanning out a question.llm_mentions(DataForSEO): 0 citations in Google’s AI answers for 5 queries the blog already ranks for, at $0.58 in credit. The answers cited YouTube, Reddit and the official docs. Five queries is a smoke test, not a study.gsc_healthover 40 priority URLs: 8 flagged as regressions. Five URLs (four posts; TypeScript in both languages) dropped from indexed to “Crawled - currently not indexed”, the English content-config post became “URL is unknown to Google”, and two were canonical-field changes. One of those was a post that actually went from “Discovered” to indexed, which my tool filed as a regression. Gaining a canonical is what indexing looks like, so that’s fixed now. Meanwhile, five URLs that weren’t indexed in June now are.gsc_hreflangandgsc_cannibalization: clean.
So June’s “healthy” was half right. The URL Inspection snapshot I saved at the end of June already had six priority URLs out of the index, so the 43 of 43 was either a coarser check or stale within weeks. Indexing churns, and a one-off audit only tells you about the day you ran it, and only as well as the check you used.
What I did with it:
- Merged the content-config post (both languages were out of the index) into my Astro 6 guide, with 301s from every old URL.
- Refreshed the other three with new first-hand content: the TypeScript post got an audit of this repo’s
tsconfig, the Vercel tests post the exact error up front, and this one the section you’re reading. - Rewrote the openings of the posts that rank, so each starts with a short answer that leads with the number, like the one at the top of this post. That’s a bet, not a result: I don’t know yet whether it moves
llm_mentionsoff zero.
Nothing here is verified yet. The same five queries and the indexing check run again on November 4, and I’ll put the numbers in this post even if they’re still zero.
The check worth stealing is the boring one. The MCP call timed out at 30 seconds on 40 URLs, so I ran the CLI directly. That one’s fixed too: the inspections now run five at a time (54 seconds for 38 URLs instead of about four and a half minutes) and the MCP waits up to five minutes for this tool. Both fixes shipped in v3.4.0. The CLI is still the command I’d start with:
ga4 gsc health --config configs/your-site.yaml --format json
Exit code 0 means no regressions (recoveries and first-run baselines don’t count), 2 means at least one URL regressed, and 1 means the run itself failed. The first run only records a baseline; the diff starts on the second. List your top 30 URLs under priority_urls and put it on a weekly cron.
What I learned building this
Google has two GA4 APIs. The Data API (query data) and the Admin API (configure). GA4 Manager uses the Admin one, which is v1alpha. Yes, alpha. Some endpoints change without warning, and certain metric types (like CURRENCY) have poorly documented restrictions. You find out the hard way.
The limits are real and it validates them before touching anything. On the free tier: 30 conversions, 50 dimensions, 50 metrics. If you’re close, it warns you before you eat a 400 from the API.
Parameter names are forever. Once you create a dimension or metric, the parameter name is permanently reserved. Even if you archive it. My fix is ugly but works: version the names (reading_time_v3).
Use it from your assistant (MCP)
The piece that changed everything was the MCP server. It exposes every tool to Claude Desktop, Claude CLI, VS Code, Cursor, and Cline, so I can say in plain language:
“Compare last week’s traffic to the prior week, audit the homepage and the worst-ranking post, and tell me what to check.”
And Claude orchestrates the calls and stitches the results together. But same principle as always: it hands me a prioritized list, not a git push. The decisions stay mine.
And here’s the connection that’s got me hooked: gsc_health is built for a cron. Which means it’s not just a tool I invoke, but a loop I can leave running: an agent that checks indexing every Monday, compares traffic, and only bothers me if something broke. That’s the next rung. You stop asking and build the loop that asks for you. Had it been on a cron since June, those regressions would have reached me the week they happened, not four months later.
What it does NOT do (let’s be honest)
- Audiences: the tool doesn’t create them. It generates the docs so you can build them by hand in the UI (the Admin API can create audiences; I just haven’t wired that into the tool yet).
- BigQuery linking: automated.
ga4 link --service bigquerycreates the export link through the Admin API. - Search Console linking: manual. The tool prints a setup guide; you finish the link in the UI.
- Historical data: this configures GA4, it doesn’t migrate data. Collection starts after setup.
Install: one script
git clone https://github.com/garbarok/ga4-manager.git
cd ga4-manager
./scripts/setup.sh
The script verifies prerequisites (gcloud, jq, curl), picks the auth path with you (ADC for personal use, service-account key for CI), enables the required APIs, smoke-tests every endpoint, and prints which manual permissions you still need. It’s idempotent: rerun it without fear.
If you have a couple of side projects you rarely touch, you probably don’t need it. If you run three or more and you’re sick of clicking the same UI for the hundredth time: try it, star it if it helps, and open an issue if something breaks (or if you find another bug like the sitemap-list one).
Related reading: why gsc_health on a cron is the natural next step: stop prompting your agent, start writing loops.
Sources
Case study: the GA4 Manager case study covers the problem, the decisions and what went wrong in the tool.
P.S. If you try it, tell me what it found on LinkedIn. Now if you’ll excuse me, I’ve got a cron to set up.
Frequently asked questions
- Is it free?
- Yes, open source under the MIT license on GitHub (garbarok/ga4-manager).
- Do I need to know Go?
- No. Download the prebuilt binary and write your configuration in YAML.
- Can it delete my data?
- No. It only uses the Admin API for configuration and cannot touch your historical analytics data. Worst case is creating or deleting config resources, which you can always recreate, and
--dry-runlets you preview everything before applying. - Do the SEO audits need a paid API key?
- Everything built on Search Console is free. The DataForSEO tools (keyword research, backlinks and
llm_mentions) spend credit from your own DataForSEO account. Otherwise you only need a free PageSpeed Insights key if you want Core Web Vitals insideseo_page_auditwith normal quota. - What is the difference between
gsc_analytics_runandgsc_traffic_compare? - The first is a snapshot of one period (top queries, pages, CTR, position). The second compares two periods and gives you URL-level drops and gains: one to see what you have, the other to see what changed.
Last updated:


