SEO Opportunity Matrix
One question: where does the next unit of SEO effort pay best? The answer is a ranked table, not an audit. Every row must name one action.
Render through growth-report-format. Open it alongside this skill. The
ranked table below is this skill's Findings section; the format skill owns the
frame, the provenance header, number formatting, and the Method block.
Tools this skill calls
| Need | Tool | Key arguments |
|---|---|---|
| Impressions, clicks, CTR, position by page | mcp__google_search_mcp__gsc_search_analytics |
dimensions: ["page"], then ["query"] per page |
| Sessions and conversions by landing page | mcp__google_search_mcp__ga4_run_report |
dimensions: ["landingPagePlusQueryString"] |
| Which events count as conversions | mcp__google_search_mcp__ga4_key_events |
property id |
Agentman defaults: GSC sc-domain:agentman.ai, GA4 property 471838641. Any
other property: resolve the target first.
Target resolution
- If the request names a property/site, use it.
- Otherwise read
data/for a registry file naming a default target. Files prefixedexample-and scrubbed placeholders never bind a default. - Otherwise ask which GSC property and GA4 property ID to run against.
Inputs (arguments with defaults)
| Argument | Default | Why it's an argument |
|---|---|---|
window |
last 28 full days | shorter is noisy, longer hides trends |
min_impressions |
100 | below this, CTR is noise |
conversion_event |
ask, or the property's starred key events | never guess what "conversion" means for a business |
Step 0 — check that conversions are measured at all
Call ga4_key_events before weighting anything by conversion, and compare
the starred list against events actually firing in ga4_run_report.
If the starred events are dead, or real intent events are unstarred, the conversion weight collapses to its floor for every page and the ranking silently degrades to "impressions × CTR gap" while still presenting itself as conversion-weighted. Say so in the output and rank on search signals alone rather than pretending the weighting worked.
This is not hypothetical. A verified state on this property: six events starred,
five with zero occurrences in 28 days, while skill_cta_click (102),
skill_try_now (70) and signup_click (10) fired unstarred. Every run before
that was fixed was effectively unweighted.
Procedure
1. Pull GSC by page
gsc_search_analytics with dimension page over window: impressions, clicks,
CTR, position. Keep pages ≥ min_impressions.
Segment before concluding — apply references/analysis-techniques.md: split
brand vs non-brand before any CTR claim, read query shapes (typed →
titles/URLs; conversational → subheads/FAQ; unserved demand → demand-test page,
never a claim), and bucket by position (4–10 is the matrix's sweet spot; 11–20
high-impression terms are content-depth wins, not title wins).
Honesty rule: only totals with totals_complete: true are safe to sum. Row
sets omit anonymized low-volume queries — never present a sum of rows as a site
total.
2. Pull GA4 by landing page
ga4_run_report with dimension landingPagePlusQueryString, metrics: sessions
and conversion_event count. Same window.
3. Join
Normalize URLs before joining (strip protocol, trailing slash, query string unless meaningful; hostnames must match). Report join coverage: "X of Y GSC pages matched a GA4 landing page." A join rate under ~70% is itself a finding (redirects, canonicals, or untagged hosts) — say so and stop ranking until explained. Then run the per-page GSC↔GA4 reconciliation check: a page whose GSC clicks and GA4 organic sessions diverge persistently has a redirect, tracking, or canonical problem, and its row is quarantined until explained.
4. Score
- CTR gap = expected CTR at its position − actual CTR, computed on the
non-brand segment. Expected-CTR defaults live in
references/expected-ctr.md; they are documented defaults, not truth — override per property once it has its own curve. - Conversion weight = conversions / session for the page, normalized against the property median (floor 0.25 so zero-conversion pages still rank by traffic value). If Step 0 found conversion measurement broken, skip this factor and say the ranking is unweighted.
- Opportunity = impressions × CTR gap × conversion weight.
5. Classify the action (one per row)
| Signal | Action | Hand off to |
|---|---|---|
| High impressions, CTR well below position-expected | title/meta rewrite | seo-title-rewrite |
| Position 11–20, high impressions | content depth + internal links | content brief |
| Ranks and clicks but doesn't convert | landing-page/CTA problem, not SEO | funnel audit |
| Converts well, position 4–10 | the priority rewrite — highest leverage in the set | seo-title-rewrite |
| Unserved demand (impressions, nothing to click) | demand-test page with its own tracked CTA | seo-keyword-competitor-research |
Before promising gains on any row, check that the diagnosis is a title problem
and not an intent mismatch. A page can rank well and still be barely clicked
because the queries feeding it are broader than anything it could satisfy;
seo-title-rewrite carries the diagnosis table that separates the two. Routing
an intent mismatch to a rewrite burns a baseline and produces a false LOSS.
Output — the Findings section
A markdown table, ranked by opportunity score, max 15 rows: page, impressions,
position, CTR (actual vs expected, non-brand), conversions, score, action.
Number formatting per growth-report-format — CTR to two decimals, position to
one, counts as plain integers.
Then the provenance header (the format skill's header-block form, since one window covers the whole table):
Source: GSC property <p>, GA4 property <id>
Window: <start>–<end> · Fetched: <timestamp>
Join coverage: <x>/<y> pages · Quarantined rows: <n>
Conversion weighting: applied | UNWEIGHTED (key events dead — see Step 0)
Expected-CTR curve: default | property-specific · Brand terms: <list-file>
Save the run to data/matrix-<target>-<date>.md so the next run can diff.
Honesty rules
- Position is an average across queries and impressions, not a rank. A page "at 7.3" may rank 2 for one query and 40 for another. When a single page drives a decision, break it down by query first.
- Never report a blended brand+non-brand CTR; a shifting brand mix masquerades as a CTR change.
- GSC and GA4 count differently (clicks ≠ sessions). Never present the join as reconciliation; it is correlation at page grain.
- CTR-gap scoring compares a page to the average page at that position. SERP features (AI overviews, shopping, snippets) depress whole-query-class CTR — and this suite cannot see them. Check one high-opportunity page's SERP by hand before promising gains, and when a CTR gap is unexplained, say the explanation may be outside what these tools return.
- If two runs disagree, re-pull before explaining. Numbers get stale; both runs carry fetch timestamps for exactly this reason.
Working copies and publishing
Public master: data/ holds fictional example-* files only. To use on a real
property, clone this skill privately and write the real registry, brand term
list, and run history into the clone. Never publish real property IDs, event
taxonomies, or run outputs — they are an analytics-poisoning kit for GA4's
unauthenticated collect endpoint and competitive intelligence besides.