---
name: analytics-report
description: Generate a daily or weekly analytics report for a customer site, comparing the current period to the previous one so the reader sees movement day-over-day or week-over-week. Use when the operator asks for "today's report," "yesterday's report," "this week's report," or any explicit period comparison.
mcp: mcp.gentic.co/analytics
tools_required:
  # Site resolution
  - gentic_ingest_list_sites
  # Recurring report — always-on
  - gentic_ingest_daily_visitors
  - gentic_ingest_top_pages
  - gentic_ingest_avg_engagement_time
  - gentic_ingest_pageviews_by_device_class
  - gentic_ingest_top_outbound_destinations
  - gentic_ingest_top_form_submits
  - gentic_ingest_top_rage_clicks
  - gentic_ingest_web_vitals_summary
  - gentic_ingest_click_counts
  - gentic_ingest_click_heatmap_grid
  - gentic_ingest_bounce_rate           # session-model, headline metric
  - gentic_ingest_top_entry_pages       # landing-page bounce breakdown
  - gentic_ingest_top_sources           # where visitors come from
  # Commerce-only
  - gentic_ingest_funnel_completion
  - gentic_ingest_checkout_dropoff
  - gentic_ingest_timeseries_purchases
  # Follow-up investigation (not in the recurring report; see §11)
  - gentic_ingest_describe_events       # free; schema introspection before _sql
  - gentic_ingest_execute_query         # 25¢; constrained SQL escape hatch
  # Also available on-demand but not for daily/weekly reports
  - gentic_ingest_sessions_summary
  - gentic_ingest_top_exit_pages
  - gentic_ingest_sessions_by_day
---

# Analytics report skill

You generate **daily** or **weekly** analytics reports by orchestrating gentic_ingest_* MCP tools. The defining feature of this skill: you always run each query **twice** — once for the current period, once for the previous period — and present **movement** (delta + direction + percent change) so the reader sees what changed, not just current totals.

A report without comparison is just a snapshot. The value you add is making the reader notice "/breath-of-gold/ jumped 12 → 415 pageviews" instead of just "/breath-of-gold/ got 415 pageviews."

---

## 1. Decide the two time windows

All timestamps are **UTC**. `since` is inclusive, `until` is exclusive.

### Daily report

Always compare two **complete** UTC days. Never compare "today so far" to "yesterday" — you'd be comparing a partial day to a full day, and that's the most common way to lie with statistics by accident.

```
Today      = 2026-05-25 (the day the report is run on)
Current    = since=2026-05-24, until=2026-05-25   (yesterday, full UTC day)
Previous   = since=2026-05-23, until=2026-05-24   (day before yesterday)
```

If the operator explicitly says "include today" or "so far today," you may run a partial-day window, but **explicitly note in the output** that "today" covers `00:00 UTC → <now>` so the comparison is honest. Prefer the full-day default.

### Weekly report

Always compare two **complete** 7-day windows ending on the most recent full UTC day.

```
Today      = 2026-05-25
Current    = since=2026-05-18, until=2026-05-25   (last 7 days, complete)
Previous   = since=2026-05-11, until=2026-05-18   (7 days before that)
```

If the operator says "this week" and "last week" specifically meaning calendar weeks (Mon–Sun), align to Monday. Default to **trailing 7 days** unless the operator says "calendar week" — trailing windows are more stable for trend detection.

---

## 2. Resolve the site (multi-site orgs)

If the operator hasn't named a domain explicitly:

1. Call **`gentic_ingest_list_sites`** (free) to enumerate sites for the org.
2. If exactly **one** non-revoked site → use that site's domain implicitly, no need to mention.
3. If **multiple** non-revoked sites → ASK the operator which one(s) to report on. Do not silently pick the oldest. List them with their domains and ask which to report on (or "all of them" — then loop the report once per site).

Once a domain is selected, **pass `domain=<value>` on every subsequent tool call** in the report. The default-without-domain resolution silently routes to the oldest non-revoked site and produces misleadingly framed answers for multi-site orgs.

---

## 3. Run the tool sequence

Run the **same set of tools twice** — once with the Current window's `since`/`until`, once with the Previous window's. Use parallel tool calls within a period if your runtime supports it; periods are independent so all calls for one period can fan out together.

### Always-on tools (every report)

| Tool                                       | What you're getting                  |
| ------------------------------------------ | ------------------------------------ |
| `gentic_ingest_daily_visitors`             | Visitor count per UTC day            |
| `gentic_ingest_top_pages` (limit: 15)      | Pageview leaderboard                 |
| `gentic_ingest_avg_engagement_time` (15)   | Wallclock + active time + scroll depth per URL |
| `gentic_ingest_pageviews_by_device_class`  | mobile vs desktop vs unknown split   |
| `gentic_ingest_top_outbound_destinations` (15) | Where readers exit to               |
| `gentic_ingest_top_form_submits` (10)      | Form submission velocity             |
| `gentic_ingest_top_rage_clicks` (10)       | UX frustration hotspots              |
| `gentic_ingest_web_vitals_summary` (15)    | LCP + CLS performance                |
| `gentic_ingest_click_counts` (50)          | Per-element click leaderboard — surfaced as movers, NOT as a full dump (see §5b) |
| `gentic_ingest_click_heatmap_grid` (10000) | Per-URL focal points + concentration shift (see §5c) |
| **`gentic_ingest_bounce_rate`**            | **Single-row: total sessions, bounces, bounce_rate_pct. THE most-requested operator metric. Always include in Traffic section.** |
| **`gentic_ingest_top_entry_pages` (15)**   | **Landing pages with per-page bounce rate. Surfaces "which entry pages lose visitors immediately"** |
| **`gentic_ingest_top_sources` (15)**       | **Top referring pages (`document.referrer`). Direct hits bucket as `(direct)`. Answers "where do visitors come from"** |

### Commerce-only tools (run only if the site sells things)

Heuristic: if the previous-period funnel_completion returned non-zero rows for `add_to_cart` or `purchase`, the site is ecommerce — run these. Otherwise skip; for non-ecommerce sites these return empty rows and add nothing.

| Tool                                  | What you're getting                  |
| ------------------------------------- | ------------------------------------ |
| `gentic_ingest_funnel_completion`     | pageview → product → cart → checkout → purchase progression |
| `gentic_ingest_checkout_dropoff`      | Step-to-step conversion percentages  |
| `gentic_ingest_timeseries_purchases`  | Hourly purchase + revenue            |

### Tools you do NOT call from this skill (but they exist for follow-ups — see §11)

- `gentic_ingest_list_available_queries` — discovery tool, not a report tool. Reports already know their own tool set; calling this on every run is wasted.
- `gentic_ingest_describe_events` — free schema-introspection tool. Use only when composing custom SQL via `_sql` (§11), not in the recurring report.
- `gentic_ingest_execute_query` — 25¢ constrained-SQL escape hatch. Reserved for follow-up investigations when the operator asks a question no predefined tool answers (§11).
- `gentic_ingest_sessions_summary`, `gentic_ingest_top_exit_pages`, `gentic_ingest_sessions_by_day` — useful follow-up surfaces but redundant with `bounce_rate` + `top_entry_pages` in the recurring report. Call on demand when the operator drills into "where do people exit," "session duration trends," etc.

Total at the default scope: **~26 paid tool calls per report** (13 always-on × 2 periods). Commerce adds ~6 more. Budget at 5¢/call = **$1.30 baseline; $1.60 for ecommerce**. Follow-up investigations via `_sql` (§11) are separately budgeted at 25¢/call.

---

## 4. Compute movement

For each metric pair (current vs previous), compute three values:

- **Absolute delta** = `current - previous`
- **Percent change** = `(current - previous) / previous * 100` (skip if previous == 0; show "new" instead)
- **Direction** = ↑ if delta > 0, ↓ if delta < 0, "—" if delta == 0

**Suppress noise.** Do NOT report a percent change when:

- Previous-period value < 5 (the percent is unstable; 1 → 2 is +100% but means nothing). Show the raw counts instead and skip the %.
- Both values are 0. Just say "no activity in either period."

**Decimal discipline.** Round percents to whole numbers. Don't say "+47.2%" — say "+47%". Decimals on percents imply precision that doesn't exist at these sample sizes.

---

## 5. Find big movers (pages, destinations, forms)

For leaderboard-shaped queries (top_pages, top_outbound_destinations, top_form_submits, web_vitals_summary):

1. Build a `Map<url, current_value>` and a `Map<url, previous_value>`.
2. Compute per-URL delta = current - previous (treat absent-in-previous as `previous = 0`).
3. Surface the **top 5 gainers** (largest positive delta) and **top 3 decliners** (largest negative delta, only if previous_value ≥ 5).
4. Surface anything that **appeared** in current that wasn't in previous (new entrants) — only if `current ≥ 10`. Otherwise it's noise.

Don't dump the whole leaderboard in the report. The "top 15 pages" leaderboard exists for context; the **movers** are what the reader is supposed to take action on.

---

## 5b. Click trends (gentic_ingest_click_counts)

The raw response is up to 50 rows per period, keyed by `(tag, class_chain)`. Roll it up two ways — the simple total first, then the element-level movers.

**Lead with total click volume.** Sum `clicks` across all rows for each period. This is the headline number: "Yesterday: 100 clicks. Day before: 50. Up 2× — something happened." Report it in the **Traffic section of the output** as a top-line metric alongside visitors and pageviews:

> **Interactive clicks:** N (Δ ±X, ±Y%)

That single line answers "is interaction up or down?" before any detail. It's often the most actionable line in the whole report — a 2× jump or a 50% drop in clicks is the kind of thing that warrants a follow-up question, and the operator should see it immediately, not hidden inside a movers section.

**Then surface element-level movers** in a separate short section:

- **Top 3 element gainers** — biggest positive delta in `(tag, class_chain)` clicks. Format each as one line: `<tag>.<class_chain>` — current clicks (Δ ±X). Examples: a CTA button getting more attention, a newly-shipped widget proving sticky.
- **Top 3 element decliners** — biggest negative delta, only if previous ≥ 10. Likely candidates: a deprecated nav link, a button that moved or was hidden.
- **Top 2 new entrants** — elements with clicks in current but absent in previous, only if current ≥ 20. New shipped element traction.

Suppress everything else. The element leaderboard is huge for SPA sites; show movers, not totals.

The total-volume line catches "something is happening." The element movers explain "where it's happening." Both go in the report; the total goes higher because it's the alarm bell.

**Caveats:**

- `class_chain` is the element's class list joined with spaces — same element with two different classes registers as two rows. Don't be surprised by near-duplicates.
- An empty `tag` or empty `class_chain` is valid (anchor tags without classes, e.g.). Don't filter these out — they often dominate.
- Sites that didn't deploy any new UI between periods will have flat element rankings — that's a signal, not a bug. Lead the "Interaction movers" section with "no significant element-level shifts" rather than fabricating movers from noise.

---

## 5c. Heatmap focal-point shifts (gentic_ingest_click_heatmap_grid)

The raw response is up to 10,000 rows of `(url, vx_pct, vy_pct, clicks)` — coordinates on a 100×100 normalized viewport grid. **Don't dump the grid in text.** Roll it up two ways, simple-total first.

**Lead with per-URL total clicks.** Sum `clicks` across all cells per URL. This gives the same "100 vs 50 — something happened" framing as §5b's total, but with per-page resolution. Report it as a small table of the top URLs by heatmap clicks:

> | URL | Current | Previous | Δ |
> | --- | ------- | -------- | -- |
> | /breath-of-gold/ | 312 | 25 | +287 ↑ |
> | /meeschell/ | 41 | 49 | -8 ↓ |
> | /signup/ | 18 | 16 | +2 — |

This is the most useful comparison and what the reader will react to first. A page going from 25 → 312 clicks is the kind of jump that demands an explanation.

**Then add a single line of focal-cell context per top URL** (only the top 3 by total clicks — more than that becomes noise):

- **Top focal cell** — the (vx_pct, vy_pct) with the most clicks. Convert to plain-language quadrant: top-left / top-center / top-right / middle / etc. Did it shift between periods?
- **Concentration ratio** — what % of total clicks land in the top 5 cells? Increasing concentration = attention narrowing onto specific elements (often a CTA). Decreasing = attention dispersing (often a sign of a redesign or a confusing page).

**Output shape inside the report:**

```
## Heatmap (top pages by click volume)

| URL                | Current | Previous | Δ      |
| ------------------ | ------- | -------- | ------ |
| /breath-of-gold/   | 312     | 25       | +287 ↑ |
| /meeschell/        | 41      | 49       | -8 ↓   |
| /signup/           | 18      | 16       | +2 —   |

Focal points (top 3):
- **/breath-of-gold/** — focal cell: top-center (unchanged). 47% of clicks in top-5 cells (was 41%) — attention narrowing onto the article headline area.
- **/meeschell/** — focal cell: middle → top-right (shifted). 28% concentration (was 34%) — attention dispersing. Worth checking if a UI element moved.
- **/signup/** — focal cell: top-center (unchanged). 91% concentration — single CTA dominating, as expected for an auth flow.
```

**Caveats:**

- The 100×100 grid is **viewport-normalized**, not page-normalized — (50, 50) means "center of the visible area when the click happened," not "middle of the page." A click at (50, 95) means "bottom of the visible viewport," which depends on scroll position. Don't over-interpret coordinates.
- Pages with < 20 total clicks across the period don't produce a meaningful "focal cell" — single clicks dominate. Skip them.
- Sites with sticky chat widgets, cookie banners, or live-chat bubbles will see a consistent focal cell in a corner. Treat fixed-position UI as a permanent hot-spot that doesn't reflect content engagement.

---

## 6. Output structure

Produce a single markdown report. The shape:

```markdown
# {Daily | Weekly} report — {domain} — {current period dates}

vs {previous period dates}

## Headlines

(3–5 bullet points: the most important movements. Lead with the biggest absolute or biggest narrative change. If nothing material moved, say "Quiet period — see breakdown below for unchanged baseline.")

## Traffic

- **Visitors:** N (Δ ±X, ±Y%)
- **Pageviews:** N (Δ ±X, ±Y%)
- **Sessions:** N (Δ ±X, ±Y%) — from `bounce_rate.total_sessions`
- **Bounce rate:** X% (was Y%) — from `bounce_rate.bounce_rate_pct`. The headline operator metric; always include this line.
- **Interactive clicks:** N (Δ ±X, ±Y%) — total from click_counts, summed across all elements. If it doubled, doubled. If it halved, halved.
- **Mobile / Desktop split:** X% / Y% (was X% / Y%)

## Top pages — movers

(Top 5 gainers, top 3 decliners. Format: URL — current views (Δ ±X). One line each.)

## Landing pages (entry-page bounce breakdown)

From `top_entry_pages`. Surface the top 3-5 entry URLs with their per-page bounce rate, current vs previous. Highlight any entry page whose bounce rate changed by ≥15 percentage points or whose session count moved ≥50%. Format:

> | Entry URL | Sessions | Bounce % | Δ |
> | --- | --- | --- | -- |
> | /home | 412 | 32% | +120 sessions, -8 pp ↑ |
> | /pricing | 78 | 67% | -22 sessions, +12 pp ↓ |

Lead with the page that has the biggest bounce-rate movement — that's almost always the actionable signal. A jump in bounce rate on a landing page usually means the page changed (content removed, hero broke, slow load) or the traffic source changed (different audience).

## Traffic sources

From `top_sources`. Top 5-10 referring URLs with movement. Note: pre-#35 events bucket as `(direct)`, so the `(direct)` row may include legitimate direct hits AND legacy events from before 2026-05-25 — the legacy tail rolls off naturally. If a new significant referrer appears in the current period that wasn't in the previous, flag it. If a previously-dominant referrer dropped substantially, flag it.

## Engagement quality

Highlight 1–3 notable shifts: pages where avg_active_ms changed by ≥30%, or scroll depth changed by ≥15 percentage points. Don't dump the full table.

## Interaction movers (from click_counts)

Top 3 element gainers + top 3 decliners + top 2 new entrants per §5b. Skip the section entirely if no element crosses the threshold (suppress empty-result sections).

## Heatmap (per-URL click totals, top pages)

Per-URL click totals from click_heatmap_grid, top 3 by current-period volume — per §5c. Lead with the table of totals + deltas; add one line of focal/concentration context per row. Skip if all top URLs have < 20 clicks (heatmap data too sparse to interpret).

## Performance (Web Vitals)

Only flag pages where LCP crossed the Good/Needs-improvement (2500ms) or Needs-improvement/Poor (4000ms) threshold between periods. Or where avg LCP changed by ≥1000ms. CLS is rarely interesting unless it crossed 0.1 (Good→NI) or 0.25 (NI→Poor).

## UX friction

- Rage clicks: report only if current count > 0 OR previous count > 0. If both 0, omit.
- Form submits: surface forms where submits-per-unique-visitor ratio is > 2 (likely retry/failure pattern).

## Outbound

Top 5 outbound destinations with movement. Skip if all values are < 5.

## Commerce (ecommerce sites only)

- Purchase count: N (Δ)
- Revenue: $N (Δ)
- Funnel-completion ratio (purchase / pageview): X% (was Y%)
- Checkout-dropoff: step that lost the most subjects this period

## Footnotes (only when relevant)

- "Unknown device_class" rows = pre-#88 legacy data. Shrinks naturally as new traffic flows in. Don't flag unless mobile + desktop together are < 30% of pageviews.
- "0 ms LCP" rows = PerformanceObserver didn't fire (very old browsers). Treat as unobserved, not "instant load."
- If a URL has weird characters (e.g. trailing `)`) — flag it once as "malformed URL likely from a broken share link," don't repeat for every section.
```

Keep the report under **600 words** for a daily, **900 words** for a weekly. The reader wants the takeaway, not the data dump.

---

## 7. Heuristics for what's actually interesting

The hard part of this skill is suppression — knowing what to leave out. Defaults:

- **Pages with < 10 pageviews in both periods** → don't surface as movers. Statistical noise.
- **A 100% jump on small numbers (e.g. 1 → 2 visitors)** → don't surface. Show raw counts only if directly asked.
- **Same top-3 pages in same order with similar volumes** → don't list them as "movers." Say "top 3 unchanged" and move on.
- **Single-day spike** in a weekly report → call it out but don't extrapolate ("Tuesday had 4× the traffic — likely an article share, not a trend").
- **Zero rage clicks in both periods** → omit the section entirely. Good UX is the default, not a finding.

If the operator asks **"what happened with X"** during a report follow-up, that's NOT this skill — that's a focused investigation. Use the on-demand tools (`gentic_ingest_click_counts`, `gentic_ingest_click_heatmap_grid`, `gentic_ingest_top_exit_pages`, `gentic_ingest_sessions_summary`, `gentic_ingest_sessions_by_day`) plus the `url` / `url_prefix` filters to zoom in. When the question doesn't fit any predefined tool, escalate to `gentic_ingest_execute_query` per §11.

**Bounce-rate-specific suppression:** a 5pp jump in bounce rate is noise; flag only ≥10pp moves at the site level, or ≥15pp at the per-entry-page level. Single-session pages have a 100% "bounce rate" by definition — don't flag those as a problem.

---

## 8. Failure modes to avoid

- **Don't compare incompatible windows.** A 7-day current vs a 24-hour previous is not a "week-over-week" comparison. Always match window lengths.
- **Don't show percent changes off small bases.** "+800%" on a single visitor going to nine is misleading. Suppress.
- **Don't omit the `domain` param on multi-site orgs.** Silent routing to the oldest site is the worst failure mode — the report looks right, the data is for the wrong site, and the reader acts on it.
- **Don't fabricate causation.** "Pageviews up 12% — likely the new feature" is reasoning the operator should do, not you. Stick to "pageviews up 12%."
- **Don't surface every web vital.** The reader wants to know "did anything cross a threshold," not see 50 LCP values.
- **Don't echo back the full top-pages leaderboard.** Movers + summary, not raw dump.

---

## 9. Example invocation walkthrough (for daily report)

Operator: "Daily report for collabs.io/mag, please."

1. Today = 2026-05-25 (the date you're called on).
2. Current = 2026-05-24 → 2026-05-25. Previous = 2026-05-23 → 2026-05-24.
3. Call `gentic_ingest_list_sites` → confirm one site or pick the right one. Say there's only `www.collabs.io/mag` for that org → no `domain` argument needed, but if multi-site → set `domain=www.collabs.io/mag`.
4. Fan out 13 tool calls for Current (parallel), 13 for Previous (parallel). Total 26 calls — the 10 traffic / engagement / outbound / forms / rage / vitals / clicks queries plus bounce_rate + top_entry_pages + top_sources (the session/sources additions from 2026-05-25 gap-closure).
5. For each pair, compute deltas. For leaderboard queries, find gainers/decliners.
6. Assemble the markdown per §6 structure.
7. Output. Suggest follow-up zoom-ins ("Want me to drill into /breath-of-gold/'s LCP? Use `url_prefix=https://www.collabs.io/mag/breath-of-gold/`").

---

## 10. Privacy and constitutional constraints

These constraints are enforced upstream — you don't have to police them — but knowing they exist shapes what you can say in a report:

- **No PII.** Subject IDs and IPs are never exposed. You can say "12 unique visitors" but cannot identify any of them.
- **No raw click coordinates.** Heatmap data is 1%-binned at capture time, so you cannot reconstruct individual session behavior.
- **No User-Agent strings.** `device_class` is the only browser-side dimension you have access to.

If a reader asks "who exactly visited /signup/?" the right answer is "the platform doesn't expose that — only aggregate counts."

---

## 11. Follow-up investigations (freeform SQL via `gentic_ingest_execute_query`)

When the operator asks a follow-up question that no predefined `gentic_ingest_*` tool answers — "compare engagement on /products/widget for mobile vs desktop visitors specifically, week over week," "list URLs where avg LCP regressed by more than 1 second since last Tuesday," "show me the 10 most-clicked elements on /signup/ ordered by click-through ratio" — you have access to a **constrained SQL escape hatch**: `gentic_ingest_execute_query`.

This is NOT a recurring-report tool. It's a follow-up investigation tool, separately budgeted at **25¢ per call** vs 5¢ for predefined tools.

### When to use it

Use `gentic_ingest_execute_query` when ALL of these are true:

1. The operator's question is specific enough to translate into one SQL query
2. None of the predefined tools (top_pages, top_entry_pages, web_vitals_summary, etc.) cover the slice they want
3. The question involves a combination of filters, joins-via-property-extraction, or per-row computations that the predefined tools don't compose

If a predefined tool fits, **use the predefined tool** — it's 5× cheaper and has guaranteed-correct shape. Don't reach for SQL just because you can.

### Workflow

**Step 1 — Discover the schema (FREE).** Call `gentic_ingest_describe_events` first. Returns per-event-type the available properties, their JSON types, non-null counts, and up to 3 sample values. You cannot write valid SQL without knowing what fields exist; guessing field names will fail with `Binder Error: column "foo" does not exist`.

**Step 2 — Compose the SQL.** The constrained whitelist (v1):

- ✅ **Allowed:** `SELECT` (one statement), `FROM events` (single table only), `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT N`
- ✅ Aggregate functions: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `approx_quantile`, `COUNT(DISTINCT …)`
- ✅ JSON: `json_extract_string`, `json_extract`
- ✅ String / math / time / conditional: `LIKE`, `LOWER`, `SUBSTR`, `CONCAT`, `ROUND`, `CAST`, `COALESCE`, `NULLIF`, `date_trunc`, `EXTRACT`, `CASE WHEN`, comparison operators
- ❌ **Forbidden:** `INSERT` / `UPDATE` / `DELETE` / `DROP` / any DDL; `JOIN` (events table only); subqueries (nested SELECT); `WITH` (CTEs in your input); `UNION` / `INTERSECT` / `EXCEPT`; multi-statement SQL

**Step 3 — Call execute_query.** Body shape:

```ts
gentic_ingest_execute_query({
  sql: "SELECT ...",         // your SELECT statement; max 8000 chars
  since: "2026-05-18",        // ISO date or datetime
  until: "2026-05-25",        // ISO date or datetime
  domain: "...",              // multi-site selector if needed
})
```

### Server-side guarantees (you don't write these in the SQL)

The server automatically:

- **Scopes to the caller's site_id.** Even if your SQL says `WHERE site_id = 'X'`, that's filtering a CTE shadow that's already site-pinned. You can only see your own org's data.
- **Scopes to the time window.** `since` / `until` are auto-injected into the underlying CTE. You can't bypass them by omitting time predicates in your SQL.
- **Caps results at 10,000 rows.** Outer LIMIT wrap.
- **5-second statement timeout.** Long queries error out cleanly.

This means: don't waste tokens writing `WHERE site_id = …` or `WHERE occurred_at BETWEEN since AND until` in your SQL. The server adds those for you. Focus on the question.

### Error handling

A 400 response includes the upstream error in the body (`{"error": "..."}`):

- `Binder Error: column "foo" does not exist` → you guessed a field name. Call describe_events first.
- `Parser Error: ...` → SQL syntax issue. Re-read the whitelist; subqueries / JOINs / CTEs are common rejections.
- `keyword 'JOIN' is not allowed in v1 ...` → the validator rejected your shape. Reformulate without the forbidden keyword.

When you get a 400, surface the error to the operator and try one re-formulation. Don't loop indefinitely.

### Example follow-up

Operator: "After today's report, can you tell me which pages saw their bounce rate jump the most week-over-week — but only for visitors from search engines?"

This doesn't fit any predefined tool. The workflow:

1. Call `gentic_ingest_describe_events` (free) → confirm `referrer` field on pageview, schema understanding
2. Compose SQL with two windows of bounce rate per entry page, filtered to subjects whose first pageview's referrer contains "google" / "bing" / "duckduckgo"
3. Call `gentic_ingest_execute_query` with the SQL (25¢) for current week
4. Call again for previous week (25¢)
5. Compare per-page bounce-rate deltas

This is **two custom queries plus one describe_events = 50¢ total**, vs the alternative of building a new predefined tool (engineering cost, days to ship). The escape hatch lets you answer the question now with bounded cost.

### What NOT to use the escape hatch for

- "What's the bounce rate?" → use `gentic_ingest_bounce_rate`, not custom SQL
- "Top 10 pages?" → use `gentic_ingest_top_pages`, not custom SQL
- "How many sessions yesterday?" → use `gentic_ingest_sessions_by_day`, not custom SQL
- Any question that already maps to a single predefined tool. The whole point of the predefined surface is to be the right shape for the common case at the cheap price.

Use the SQL endpoint when the operator is reaching beyond the predefined surface. Not when they could have asked a cheaper question.
