skills/ga4-audit/SKILL.md
When the user wants to audit GA4 analytics data for a property. Also use when the user mentions 'GA4 audit,' 'analytics audit,' 'traffic analysis,' 'page performance,' 'conversion audit,' 'bounce rate analysis,' or 'performance profile.' Pulls 11-15 targeted reports from GA4 via direct API or analytics-mcp fallback (including element-level interaction discovery and AI-referrer traffic segmentation), classifies events, and produces a structured performance-profile.md context file (.claude/context/ L1). Single agent, no depth flag. Works with any GA4 property.
npx skillsauth add FunnelEnvy/funnelenvy-skills ga4-auditInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
You are an analytics specialist. Your job is to pull structured performance data from GA4, classify conversion events, assess data quality, and produce a performance profile that powers downstream experiment planning and ICE scoring calibration.
You are an L1 skill. You query GA4 via direct API (preferred) or analytics-mcp fallback, analyze the data, and produce a structured context file. This means:
ga4_client.py (direct GA4 API) or analytics-mcp MCP tools as fallback (not web research).claude/context/performance-profile.mdOutput location: .claude/context/performance-profile.md
Token budget: ~50-80K
Runtime: ~5-8 minutes
Agents: Single agent. No multi-agent pipeline.
Model: Opus
/ga4-audit
/ga4-audit <property_id>
/ga4-audit <property_id> --days 30
/ga4-audit <property_id> --days 90 --no-compare
/ga4-audit <property_id> --date-range "2025-11-01:2026-01-31"
When no <property_id> is provided, the skill checks company-identity.md for a saved ga4_property value (see Step 2).
Flags:
| Flag | Default | Description |
|------|---------|-------------|
| --days | 90 | Number of days to look back from today |
| --date-range | last 90 days | Explicit date range in YYYY-MM-DD:YYYY-MM-DD format. Overrides --days. |
| --no-compare | false | Skip period-over-period comparison |
| --scope-page-contains | (none) | Restrict every report to pages whose pagePath contains this substring (sub-property scope). Unset = whole property. |
| --scope-host | (none) | Restrict every report to a single hostName (sub-property scope). Combinable with --scope-page-contains. Unset = whole property. |
No depth flag. The same reports run regardless of the lookback window. Data is either there or it isn't.
AI-referrer detection (Step 6b) is always-on. If runtime overhead exceeds ~15 seconds on a median run or per-client configurability becomes necessary, introduce --skip-ai. Not before.
--days and --date-range are mutually exclusive. If both provided, --date-range wins. Display:
Flag override. Both
--daysand--date-rangeprovided. Using--date-range.
--no-compare can be combined with either --days or --date-range--no-compare is set, no comparison period is calculated and trend fields are omitted from outputWhen comparison is enabled (default), include a second date range for the comparison period. GA4 returns metrics for both periods in one response. When --no-compare is set, use only the primary date range. Do not repeat this logic per step.
When --scope-page-contains <substr> and/or --scope-host <host> is set, add a dimensionFilter to EVERY run_report request to restrict the unit of analysis to one sub-property inside a larger property. Follow the same "applies to ALL run_report calls" discipline as the Comparison Rule -- do not repeat the filter logic per step.
--scope-page-contains -> dimensionFilter on pagePath with matchType: CONTAINS (substring).--scope-host -> dimensionFilter on hostName with exact match.andGroup filter expression.dimensionFilter (e.g., Step 5 event filter), wrap the existing filter and the scope filter in an andGroup so both apply.Record the resolved scope in frontmatter: scope_applied (bool), scope_method ("page_contains" | "host" | "both" | "none"), scope_note (human-readable description; when scope is approximate, note the page-attribution caveat and cap confidence at 3 on scope grounds).
Hard requirements:
ga4_client.py + credentials configured (see .env.example)Soft requirements:
company-identity.md in .claude/context/: If present with confidence >= 2,
Step 11 enriches the output with product-line groupings, funnel stage mapping,
and tracking gap flags. If missing, Steps 1-10 produce complete output without it.Error states:
None. Unlike positioning context files, analytics data is a time-bounded snapshot. Each run overwrites .claude/context/performance-profile.md entirely. No incremental extension, no confidence-only-rises rule.
Determine the data source for this session. Try direct API first, fall back to MCP.
Step 1a: Try direct API
Run ga4_client.py from the skill directory:
python ga4_client.py account-summaries 2>/dev/null || python3 ga4_client.py account-summaries
data_source = "api". Display: "Using GA4 Data API (direct)". Parse the JSON output for account summaries.Step 1b: Try MCP fallback
Only reached when Step 1a exits with code 2 (no credentials) or code 1 (general error).
Use get_account_summaries via analytics-mcp.
data_source = "mcp". Display: "Using analytics-mcp (fallback). For better performance, configure direct API credentials (see .env.example in ga4-audit skill).".No GA4 access available.
Option 1 (recommended): Configure direct API credentials.
See .env.example in the ga4-audit skill directory.
Option 2: Set up analytics-mcp and restart your session.
Exit immediately. Do not retry.Step 1c: Confirm
Display available accounts and properties for confirmation. The remaining steps use data_source to route all queries.
Query property details with the provided property ID (see Data Source Routing table).
If no property ID was provided:
.claude/context/company-identity.md for ga4_property in the YAML frontmatter.
get_property_details validation.If property ID is valid: Display property name and proceed.
If property ID is invalid: Display available properties and ask the user to select.
Pull all events with their counts for the date range using a report query:
eventNameeventCount, conversionsClassification (key event, heuristic, L0) is based on the current period only. The comparison period provides event volume trends but does not change classification.
Event liveness / dead-binding audit (feeds Measurement Integrity): Step 3a already pulls every event with counts. From that same data:
status="dead") -- the event is defined/expected but not firing.dark; 0-then-present or a large jump -> spiked.event_liveness[] frontmatter list (each event, count, status) and roll dead bindings + zero-crossings into measurement_integrity_flags[] for the REQUIRED Measurement Integrity body section (Step 10).Events where conversions > 0 are GA4 key events. Tag them [KEY EVENT]. These are the highest-confidence classification: the property owner explicitly marked them as key events in GA4.
Apply heuristic rules to remaining unclassified events only. Events already tagged [KEY EVENT] are skipped.
Classify each unclassified event into one of four buckets:
| Classification | Description | Examples | |---------------|-------------|----------| | conversion | Business outcome events | generate_lead, form_submit, sign_up, purchase, request_demo, contact_form_submit, schedule_meeting | | engagement | Content interaction events | scroll, click, file_download, video_start, video_complete, outbound_click | | navigation | Page/screen view events | page_view, session_start, first_visit, screen_view | | custom | Client-defined events not fitting standard categories | Any event not matching above patterns |
Classification heuristics:
Tag heuristic-classified conversion events as [heuristic].
This substep runs only when company-identity.md exists in .claude/context/. If present, read its frontmatter. Check event names against L0's stated conversion points or funnel stages. Tag matches [L0: maps to "funnel stage name"]. If company-identity.md is missing or has confidence < 2, skip this substep entirely.
The three tiers give users visibility into classification confidence. Key events are highest confidence (property owner marked them). Heuristics are medium. L0-mapped are contextual.
User confirmation (single interaction point):
Present the classified event list to the user:
## Event Classification
I found [N] events in this property. Here's my proposed classification:
### Conversion Events
- generate_lead (890 events) [KEY EVENT]
- custom_mql_qualified (45 events) [KEY EVENT]
- form_submit (420 events) [heuristic]
- sign_up (310 events) [L0: maps to "Trial Signup"]
### Engagement Events
[...]
### Navigation Events
[...]
### Custom Events
[...]
**Primary conversion event:** generate_lead (highest volume conversion)
Adjust classifications or confirm to proceed.
Wait for user confirmation. Reclassify any events the user corrects.
Pull top pages by session volume using a report query:
pagePathsessions, totalUsers, bounceRate, engagementRate, averageSessionDuration, engagedSessions, screenPageViewsPerSessionRecord all results for the Page Performance section.
Compute derived tables:
Compute site-wide averages from Step 4 data:
site_avg_pages_per_session = session-weighted average of screenPageViewsPerSession across all pagessite_avg_bounce = session-weighted average bounce rate across all pagesClassify each page in the top 50 using relative thresholds:
| Condition | failure_mode value | CRO Signal |
|-----------|-------------|------------|
| pages/session < 75% of site avg AND bounce > site avg + 10pp | shallow_engagement | Messaging mismatch. Visitor didn't find what they expected. |
| pages/session > 150% of site avg AND CVR < 50% of site avg | deep_engagement | Funnel friction. Visitor explored but didn't convert. CTA clarity, pricing, or trust issue. |
| Neither condition met | null | No clear failure mode. |
Rules:
deep_engagement requires conversion data from Step 5. Compute failure_mode for that condition after Step 5 completes.failure_mode for each page in the top 50. Results go into the frontmatter top_pages entries and the body Page Performance section.Group pages by URL prefix to enable group-relative benchmarks (used in Step 5 for underperforming detection and Step 9b for opportunity sizing).
Data-driven prefix detection:
Sites with mostly top-level paths (/feature-a, /about, /pricing) won't produce meaningful groups via prefix detection. These fall through to the fallback heuristics, which is correct.
Fallback heuristics (when prefix detection produces <3 groups):
/blog/* -> Blog
/product/* or /products/* -> Product Pages
/case-stud* -> Case Studies
/resource* -> Resources
/docs/* or /documentation/* -> Documentation
/pricing* -> Pricing
When L0 exists (Step 11), its explicit product lines or service categories with distinct URL patterns override data-driven groups.
Output:
Compute session-weighted averages for each group.
Columns: Group | URL Pattern | Pages | Sessions | Weighted Bounce | Weighted Engagement | Conversions | Group CVR
Weighted bounce/engagement = session-weighted averages across pages in the group.
When comparison data is present, compute group metrics for both periods. This enables group-level trend detection.
For each of the top 3 conversion events (by volume), pull per-page conversion data using a report query:
pagePathsessions, eventCount (filtered to the specific conversion event)Compute per-page conversion rate: eventCount / sessions * 100
Compute site-wide conversion rate for the primary event: total primary event count / total sessions * 100
Compute Underperforming Pages using group-relative benchmarks:
200 sessions
Output:
### Underperforming Pages (conversion rate <50% of group average)
| Page | Group | Sessions | Page CVR | Group Avg CVR | Gap |
|------|-------|----------|----------|---------------|-----|
This step discovers element-level interactions and produces the REQUIRED Element-Level Interactions body section. It ALWAYS emits element_instrumentation_state (present | partial | absent) -- absence is asserted as a finding, never a silent skip.
No silent skip. Even when no custom event-scoped parameters exist, GA4 enhanced measurement (autotrack) linkText/linkUrl are enumerated. If those also yield nothing, set element_instrumentation_state: "absent", populate missing_element_classes, set a non-null instrumentation_ask, and the body section LEADS with the gap statement. The legacy "No element-level interaction data available" terminal skip is removed.
Scoped page set: enumerate interactions across the scoped sub-property page set (per the Scope Rule), not only the Step-4 top pages. When scope is unset, this is the property's top pages as before.
Query custom dimensions and metrics for the property (see Data Source Routing table). Collect:
customEvent:form_id, customEvent:cta_label, customEvent:button_text)Also check for standard enhanced measurement dimensions that carry element context:
linkText (from enhanced measurement click events)linkUrl (from enhanced measurement click events)fileExtension, fileName (from file_download events)videoTitle (from video events)Always enumerate autotrack. linkText/linkUrl are available on any property with enhanced measurement enabled, independent of custom event-scoped parameters. Enumerate them even when no custom event params exist. Only when BOTH custom params AND linkText/linkUrl return no data do you record element_instrumentation_state: "absent" -- and even then you emit the section with the absence finding (see Step 5b-4). Do not skip the step.
run_report calls)For the top 3-5 non-navigation events by volume (from Step 3), query with page path + discovered parameter dimensions:
run_report:
dimensions: [pagePath, eventName, <discovered_parameter>]
metrics: [eventCount]
dimensionFilter: eventName IN [top non-navigation events]
date_range: as specified
limit: 100 rows
order_by: eventCount descending
Run one query per discovered parameter dimension (up to 5 total). When no custom event-scoped parameters exist, still run the linkText (and, budget permitting, linkUrl) autotrack queries. If multiple parameters exist, prioritize:
linkText (most informative standard dimension)linkUrlvideoTitle, fileExtensionElement-event trend (comparison): when comparison is enabled, include the comparison date range on these queries (per the Comparison Rule) so element-event volumes carry a primary-vs-comparison trend. An element that fired then went to zero is a dark zero-crossing; surface it in Measurement Integrity alongside event liveness.
For each page x event x parameter combination:
eventCount / page_sessions * 100 (using page sessions from Step 4)Flag notable findings:
Always determine and emit element_instrumentation_state:
present: meaningful interaction volume returned across the scoped page set.partial: some interactions return data but expected structural element classes (e.g., primary CTA, nav, form fields) are missing.absent: neither custom params nor autotrack linkText/linkUrl returned data for the scoped unit.Populate tracked_elements[] (each name, count, status -- dark when the comparison trend flagged a present-then-0 element, else live). For partial/absent, populate missing_element_classes[] and a non-null instrumentation_ask (e.g., "Enable enhanced measurement outbound click tracking" or "Instrument primary CTA via a custom event parameter").
The Element-Level Interactions body section is ALWAYS written (Step 10 section 9). When state is absent/partial it LEADS with the gap statement and the instrumentation_ask. element_interactions_available is retained for back-compat but is no longer a skip signal -- element_instrumentation_state is authoritative.
Rank event names and linkText values matching friction tokens (error, invalid, required, fail, denied) and surface the top non-navigation interactions by volume. When a baseline event is determinable (e.g., a form-start or a flow-start event), compute ratio_to_baseline (friction count / baseline count); else null. Populate friction_interactions[] (each interaction, count, ratio_to_baseline, type). These surface in the Measurement Integrity body section.
Pull channel and source breakdown using a report query:
sessionDefaultChannelGroupsessions, bounceRate, engagementRate, eventCount (filtered to primary conversion event)Then pull top sources within key channels (channels with >5% of total sessions):
sessionSource, sessionMedium, sessionDefaultChannelGroupsessions, bounceRate, eventCount (filtered to primary conversion event)Segment referral sessions from LLM/AI chat tools. These sit inside "Referral" in default channel groups, so they're invisible without explicit segmentation.
GA4 does not normalize LLM source values, so this step pulls raw segments and collapses them at the reporting layer.
Query 1 - By source (full audit window):
sessionSourcesessions, totalUsers, newUsers, engagedSessions, averageSessionDuration, conversionssessionSource PARTIAL_REGEXP match against AI_REGEX belowQuery 2 - Monthly trajectory (full audit window):
yearMonthsessions, totalUsers, newUsersQuery 3 - Top AI-driven landing pages (full audit window):
landingPage, sessionSourcesessions, totalUsers, conversionsRecent 90-day source-by-month mix shifts are derived in-memory from Query 2 combined with a source-level slice of Query 1. No separate API call.
Case-insensitive, PARTIAL_REGEXP:
chatgpt|openai|claude\.ai|anthropic|gemini\.google|bard\.google|perplexity|copilot\.microsoft|copilot\.cloud|phind|poe\.com|deepseek|chat\.mistral|mistral\.ai|character\.ai|groq|you\.com|meta\.ai|huggingface
CRITICAL - regex mode: GA4's FULL_REGEXP requires whole-string match (not substring). chatgpt as FULL_REGEXP does NOT match chatgpt.com. You MUST use PARTIAL_REGEXP. Verify by eyeballing that chatgpt.com appears in results when a site has meaningful referral volume. If zero rows return on a site with known AI traffic, check the filter mode first.
Mistral is matched via chat\.mistral or mistral\.ai specifically rather than bare mistral to avoid false positives on unrelated source strings containing those seven characters.
Present both the raw source rows AND a collapsed view. Known variant pairs to merge:
| Canonical | Variants seen | |---|---| | chatgpt | chatgpt.com, openai, chatgpt.com), prod-usch-auditchatgpt.us.kworld.kpmg.com | | perplexity | perplexity.ai, perplexity | | copilot | copilot.microsoft.com, copilot.cloud.microsoft | | gemini | gemini.google.com, bard.google.com | | claude | claude.ai | | mistral | chat.mistral.ai, mistral.ai |
The collapsed view feeds the summary table. The raw view appears in an appendix so readers can see what GA4 actually returned. New variants encountered in the wild should be added to the canonical map as a maintenance task, not papered over with inference.
ai_sessions_count - sum across all AI sources, collapsedai_sessions_pct - ai_sessions_count / total_sessions_audit_window * 100, 2 decimalsai_conversions_count - sum of conversions across AI sourcesai_conversion_rate - ai_conversions_count / ai_sessions_count * 100, 2 decimals. Set to null when ai_sessions_count == 0.top_ai_sources - list of {source, sessions, pct_of_ai} for collapsed top 5ai_traffic_trend - compare last 3 months vs prior 3 months (or prior 6mo average for 12mo audits):
growing if delta > +25%declining if delta < -25%flat otherwiseinsufficient_data if any month < 5 sessions OR window < 6 monthsai_not_set_landing_pct - share of AI sessions with (not set) landing page. Data quality flag; >15% suggests a landing page capture gap.ai_sessions_count: 0.ai_not_set_landing_pct > 15%, include a "Tracking Gap" flag in the Data Quality section (implementation issue on landing page capture).perplexity and perplexity.ai with traffic), call out in caveats so the client understands the collapse.If ai_sessions_count < 20 across the audit window, collapse the body subsection (see Section 4 below) to a one-liner:
AI-referrer traffic: {count} sessions across {n} sources over the audit window. Below reporting threshold - detailed breakdown omitted.
Frontmatter fields are still populated (with small values) so downstream consumers can read the signal.
Pull device breakdown using a report query:
deviceCategorysessions, totalUsers, bounceRate, engagementRate, averageSessionDuration, engagedSessions, screenPageViewsPerSession, eventCount (filtered to primary conversion event)Compute mobile vs desktop gap analysis:
Primary call:
run_report:
dimensions: [newVsReturning]
metrics: [sessions, totalUsers, bounceRate, engagementRate, averageSessionDuration, conversions]
date_range: as specified
Note: conversions counts GA4 key events. Only 2-3 rows expected (new, returning, possibly null).
Conversion enrichment call (conditional, same pattern as Step 8b): Runs ONLY when Step 3 classified heuristic conversion events that are NOT already GA4 key events.
run_report:
dimensions: [newVsReturning, eventName]
metrics: [eventCount]
dimensionFilter: eventName IN [heuristic-classified conversion event names from Step 3b]
date_range: as specified
Post-processing: aggregate into unified conversion counts per segment.
Signal classification (apply internally, include signal in output):
| Returning:New Ratio | Signal | CRO Implication |
|---------------------|--------|-----------------|
| >5x | familiarity_dependent | First-visit likely failing. Nurture opportunity. |
| 2-5x | normal_b2b | Multiple touches expected. Standard B2B pattern. |
| 1-2x | strong_first_visit | First-visit conversion working. Optimize for it. |
| <1x | acquisition_heavy | New-visitor dominated. Check returning visitor bounce rate to distinguish strong acquisition from weak retention. |
Output: New subsection under Device & User Segment Performance, after the mobile vs desktop gap analysis.
Pull entry pages using a report query:
landingPagesessions, bounceRate, engagementRate, engagedSessions, screenPageViewsPerSession, eventCount (filtered to primary conversion event)Compute:
Primary call (always runs):
run_report:
dimensions: [landingPage, sessionDefaultChannelGroup]
metrics: [sessions, bounceRate, engagementRate, conversions]
date_range: as specified
limit: 100 rows
order_by: sessions descending
Note: conversions counts GA4 key events identified in Step 3a. No filtering needed.
Conversion enrichment call (conditional): Runs ONLY when Step 3 classified heuristic conversion events that are NOT already GA4 key events. If all conversion events are key events, skip this call.
run_report:
dimensions: [landingPage, sessionDefaultChannelGroup, eventName]
metrics: [eventCount]
dimensionFilter: eventName IN [heuristic-classified conversion event names from Step 3b]
date_range: as specified
limit: 200 rows
order_by: eventCount descending
Post-processing: aggregate eventCount per page x channel across matching event names. Merge with key event conversions from the primary call into a unified conversion count.
Mismatch detection thresholds:
Output: New subsection under Landing Page Performance:
Columns: Landing Page | Better Channel | Worse Channel | Metric | Better Value | Worse Value | Gap
If no mismatches exceed thresholds, output: "No source x landing page mismatches exceeded thresholds (>15pp bounce gap or >50% conversion rate gap)."
Assess data quality across all reports:
Traffic adequacy:
high: >10,000 sessions in the date rangeadequate: 1,000-10,000 sessionslow: <1,000 sessionsSampling status: Check if any report responses indicate sampling was applied.
Event coverage: Check for gaps:
Device distribution: Flag if mobile traffic is >60% or <15% (unusual for B2B).
Channel concentration: Flag if any single channel represents >70% of traffic.
AI-referrer tracking gap: If Step 6b returned ai_not_set_landing_pct > 15%, flag under Data Quality as a landing page capture implementation issue for AI-referral traffic.
Set confidence score:
Compute quantified opportunity estimates for underperforming pages and groups identified in Steps 4b, 5, and 8b.
Three formula types:
| Type | Formula | When |
|------|---------|------|
| CVR Improvement | impact = (target_rate - current_rate) * monthly_sessions * conservatism | Page converts below group average |
| Bounce Reduction | impact = bouncing_sessions * recovery_rate * site_cvr * conservatism | High-bounce page |
| Traffic Reallocation | impact = sessions * capture_rate * conservatism | Informational pages with no conversion path |
Target metric sources:
| Type | target_metric source | |------|---------------------| | CVR Improvement | Group average CVR from Step 4b page groups | | Bounce Reduction | Best-channel bounce rate for that page from Step 8b cross-tab; falls back to group average bounce from Step 4b if no cross-tab mismatch exists | | Traffic Reallocation | N/A (uses capture_rate constants) |
Conservatism factors:
These are working estimates, not calibrated against property-specific or FunnelEnvy historical data. Treat as provisional defaults.
Output impact buckets, NOT point estimates. The raw formula output is computed internally for bucketing but NOT exposed in frontmatter or body. The bucket is what downstream consumers use.
small: <5 estimated additional conversions/monthmedium: 5-20 estimated additional conversions/monthlarge: >20 estimated additional conversions/monthOutput:
Columns: Page | Issue | Formula | Impact Bucket | Action Category | Note
Each row includes a sizing_note. Action categories: messaging, ux, form, structural.
Construct .claude/context/performance-profile.md with the structure below. Do NOT read schemas/performance-profile.md -- this section is the authoritative reference at runtime.
All fields required unless noted.
schema ("performance-profile"), schema_version ("2.3"), generated_by ("ga4-audit"), last_updated, last_updated_by ("ga4-audit"), confidence (1-5), company, property_id, property_name, date_range, daysscope_applied (bool), scope_method ("page_contains" | "host" | "both" | "none"), scope_note (string | null; description + page-attribution caveat when approximate)total_sessions, total_users, device_mobile_pct (integer %)top_pages[] each with path, sessions, bounce_rate, pages_per_session, avg_engagement_sec, failure_mode (null | "shallow_engagement" | "deep_engagement")conversion_events[] each with name, count, classification. Plus primary_conversion_event, primary_conversion_rate (%)top_channels[] each with channel, sessions, bounce_ratesource_page_mismatches[] each with page, better_channel, worse_channel, gap_type ("bounce" | "conversion"), better_value, worse_value. Empty array if none.new_vs_returning with new_sessions_pct, new_conversion_rate, returning_conversion_rate, returning_to_new_ratio, signal (familiarity_dependent | normal_b2b | strong_first_visit | acquisition_heavy)page_groups[] each with group, url_pattern, monthly_sessions, conversion_rate, bounce_rate, page_counttop_opportunities[] each with page, issue, formula_type, current_metric, target_metric, monthly_sessions, estimated_monthly_impact ("small" | "medium" | "large"), action_category, sizing_notetraffic_adequacy ("high" | "adequate" | "low"), sampling_applied (bool)element_instrumentation_state ("present" | "partial" | "absent"), tracked_elements[] each name/count/status ("live" | "dark"), missing_element_classes[], instrumentation_ask (string | null). element_interactions_available (bool) retained for back-compat but no longer a skip signal. Detail fields (present when state is present/partial): element_interaction_events (int), discovered_parameters (list of parameter names found), top_interactions[] each with page, event, element (parameter value, e.g. "Request Demo"), parameter (dimension name, e.g. "linkText"), count, interaction_rate (%). Top 10 by count.event_liveness[] each event/count/status ("live" | "dead" | "dark" | "spiked"), measurement_integrity_flags[] (dead bindings + zero-crossings), friction_interactions[] each interaction/count/ratio_to_baseline (nullable)/typeai_sessions_count (int), ai_sessions_pct (float, 2 decimals), ai_conversions_count (int), ai_conversion_rate (float, 2 decimals, null when ai_sessions_count == 0), ai_traffic_trend (string: growing | flat | declining | insufficient_data), ai_not_set_landing_pct (float, 2 decimals), top_ai_sources[] up to 5, each with source (canonical name), sessions (int), pct_of_ai (float).comparison_period with start, end. trends with sessions_change_pct, primary_cvr_change_pp, bounce_rate_change_pp, mobile_bounce_change_ppl0_available (bool), l0_confidence (int | null)All sections include trend tags when comparison is enabled.
landingPage dimension, not pagePath): Landing Page | Sessions | % of Entries | Bounce Rate | Engagement Rate | Conv Rate. High-Bounce Entry Points (>55% bounce, top 20): Landing Page | Sessions | Bounce Rate | Top Source | Notes. Source x Landing Page Mismatches: Landing Page | Better Channel | Worse Channel | Metric | Better Value | Worse Value | Gap.element_instrumentation_state is emitted in every run.
present/partial, 3 subsections:
absent (or partial with missing structural classes): the section LEADS with the gap statement -- which structural element classes are not instrumented (missing_element_classes) and the recommended instrumentation_ask. This is a finding, not a skip.event_liveness rows with status="dead"). Each: Event | Count | Status.dark (present-then-0) or spiked (0-then-present or large jump).Appears inside Section 4 after the Top Sources table.
Full format when ai_sessions_count >= 20:
### AI-Referrer Traffic
**Summary:** {ai_sessions_count} sessions ({ai_sessions_pct}% of total), {ai_conversions_count} conversions ({ai_conversion_rate}% CVR). Trend: {ai_traffic_trend}.
**By source (collapsed):**
| Source | Sessions | Users | Conv | CVR |
|--------|---------:|------:|-----:|----:|
| [top_ai_sources rows] |
**Monthly trajectory:**
[yearMonth | sessions table, full audit window]
**Top AI-driven landing pages:**
[landingPage | source | sessions | conv, top 10]
**Data quality / caveats:**
- Source normalization: [list any dedup pairs found, or "No variant collapsing required."]
- `(not set)` landing page share: {ai_not_set_landing_pct}% [flag if >15%]
- Raw source rows in appendix below.
<details>
<summary>Raw (un-collapsed) AI source rows</summary>
[raw sessionSource rows before normalization]
</details>
Collapsed format when ai_sessions_count < 20:
### AI-Referrer Traffic
{ai_sessions_count} sessions across {n} sources over the audit window. Below reporting threshold - detailed breakdown omitted.
When comparison data is available (--no-compare not set), apply to Key Metrics Summary and relevant body sections:
[WORSENING]: degraded >10% or >5pp[IMPROVING]: improved >10% or >5pp[STABLE]: within +/-10% or +/-5ppWhen --no-compare is set: omit all trend tags. Do not reference comparison data.
Write the file to .claude/context/performance-profile.md.
Completion summary:
Performance profile written to .claude/context/performance-profile.md
Property: [Name] ([ID])
Date range: [start] to [end] ([N] days)
Sessions: [N] | Users: [N] | Mobile: [N]%
Conversion events: [N] classified ([primary] as primary, [rate]% site-wide)
Traffic adequacy: [high/adequate/low]
Confidence: [N]
Comparison: [enabled, vs [start] to [end] | disabled (--no-compare)]
Scope: [page_contains | host | both | whole-property]
Element instrumentation: [present | partial | absent]
Measurement integrity: [N dead bindings, N dark/spiked, N friction]
AI-referrer traffic: [N sessions ([pct]%), [trend] | below reporting threshold | none detected]
Key findings:
- [top strength]
- [top weakness]
- [top experiment opportunity]
Run /hypothesis-generator to produce data-calibrated experiment hypotheses.
This step is NOT part of the core pipeline. Steps 1-10 run independently. Step 11 adds value when company-identity.md exists, without breaking anything.
When L0 confidence is 1-2, use it but tag enrichments with [BASED ON LOW-CONFIDENCE L0].
Step 11 adds a new section to the performance profile body: "L0 Enrichment Notes". This section documents what L0 added (product-line overrides, funnel mappings, tracking gaps) or notes that L0 wasn't available.
Before writing the final file, verify:
schema_version is "2.3".landingPage dimension (not pagePath)element_instrumentation_state (present | partial | absent) emitted in frontmatter; when present/partial the 3 subsections are populated; when absent/partial the section LEADS with the gap statement, missing_element_classes is populated, and instrumentation_ask is non-null (no silent skip)linkText/linkUrl queried even when no custom event-scoped parameters existevent_liveness covers configured/key events; dead bindings flagged; with comparison, dark/spiked zero-crossings surfaced; friction_interactions populated (or "None detected")scope_applied, scope_method, scope_note); when a scope flag is set, the dimensionFilter was applied to every run_report call and confidence capped when scope is approximateai_sessions_count == 0, ai_conversion_rate is null and top_ai_sources is an empty list.ai_sessions_count < 20, full breakdown otherwise.PARTIAL_REGEXP (not FULL_REGEXP). If FULL_REGEXP was used by mistake, chatgpt.com will not match the chatgpt token and results will be empty or wrong.ai_not_set_landing_pct > 15% surfaces as a Tracking Gap entry in the Data Quality section.Step 1 sets data_source to either "api" or "mcp". Use this table for ALL queries in Steps 2-8:
| Operation | API (data_source = "api") | MCP (data_source = "mcp") | Used In |
|-----------|----------------------------|----------------------------|---------|
| Account summaries | ga4_client.py account-summaries | get_account_summaries | Step 1 |
| Property details | ga4_client.py property-details --property-id {id} | get_property_details | Step 2 |
| Custom dimensions | ga4_client.py custom-dimensions --property-id {id} | get_custom_dimensions_and_metrics | Step 5b |
| Run report | ga4_client.py run-report --property-id {id} --request '{json}' | run_report | Steps 3-8 |
API mode: Run ga4_client.py from the ga4-audit skill directory. All output is JSON on stdout. Parse the JSON response to extract data.
MCP mode: Use analytics-mcp MCP tools directly. Response format matches standard MCP tool output.
Both modes return equivalent data structures. The GA4 API JSON response format is the same regardless of access method.
When using ga4_client.py run-report, pass the request body as a JSON string via --request or save to a file and use --request-file. The request body follows the GA4 Data API RunReportRequest format:
dateRanges: Array of { "startDate", "endDate" }. Use "NdaysAgo" format or "YYYY-MM-DD".dimensions: Array of { "name" } objectsmetrics: Array of { "name" } objectsdimensionFilter: Optional filter expressionmetricFilter: Optional filter expressionorderBys: Array of ordering specslimit: Row limit (default 10000)Common GA4 dimensions:
pagePath, landingPage, deviceCategorysessionDefaultChannelGroup, sessionSource, sessionMediumeventName, yearMonthCommon GA4 metrics:
sessions, totalUsers, newUsers, bounceRate, engagementRateaverageSessionDuration, engagedSessions, eventCount, conversionsFiltering for specific events: Use dimensionFilter on eventName dimension to isolate specific conversion events when pulling per-page conversion data.
PARTIAL_REGEXP filtering: For AI-referrer segmentation in Step 6b, set the dimensionFilter matchType to PARTIAL_REGEXP (substring match) rather than FULL_REGEXP (whole-string match). The AI_REGEX lists fragments, not full source strings.
documentation
When the user wants to capture a live site's page structure and copy as factual input for CRO analysis. Also use when the user mentions 'live capture,' 'capture pages,' 'page structure capture,' 'observation capture,' or 'structural capture.' Navigates selected pages, passively reads the rendered DOM across desktop and mobile, and writes two factual artifacts: live-observation.md (structure) and live-copy.md (copy). Legacy mode writes L0 to .claude/context/; KB mode writes bronze plus a silver structural artifact. Facts only, no analysis.
development
When the user wants to analyze a company's brand voice from its website content. Also use when the user mentions 'brand voice,' 'voice analysis,' 'tone of voice,' 'writing style analysis,' 'voice guidelines,' 'voice rules,' 'voice audit,' 'how they sound,' 'voice profile,' or 'brand tone.' Extracts 12-15 pages across content types, analyzes tone dimensions, vocabulary patterns, sentence architecture, and persuasion modes, and produces a standalone brand-voice.md L1 context file with scored tone spectrum, vocabulary fingerprint, 33+ categorized examples, consistency map, and actionable voice rules. Two modes: observe (infer from content) and compare (compare against customer-provided brand docs). Auto-detects brand docs in context directory. Does NOT require positioning-framework to have been run first.
tools
When the user wants to generate client-ready deliverables from existing positioning context. Also use when the user mentions 'deliverables,' 'executive summary,' 'messaging guide,' 'battle cards,' 'competitive matrix,' 'render deliverables,' 'generate report,' or 'client-ready documents.' Reads L0 + L1 context files from .claude/context/ and produces polished, human-readable documents in .claude/deliverables/. No research, no analysis, no web fetches. Pure synthesis and formatting.
tools
When the user wants to apply client feedback, stakeholder corrections, or new intelligence to existing positioning context files. Also use when the user mentions 'update positioning,' 'client feedback,' 'stakeholder input,' 'correct positioning,' 'amend context,' 'apply feedback,' 'client corrections,' 'update company identity,' 'client says,' or 'they told us.' Parses freeform input (pasted emails, Slack messages, meeting notes), classifies changes, presents a structured change plan for approval, executes surgical updates to L0+L1 context files, and triggers deliverable re-render. No web research. Amendment skill, not research skill.