# Tracking reference

> Plaintext version for LLMs. Canonical HTML: https://askbowtie.com/docs/

One tag tracks everything automatic. Four calls cover everything custom: events, conversions with revenue, errors, guardrails. All of it lands in one database your AI agent queries over MCP.

Bottom line

One script tag tracks pageviews, clicks, forms, errors, and Web Vitals automatically. Four calls cover everything custom: `bowtie.track()`, `bowtie.converted()`, `bowtie.error()`, and `bowtie.guardrail()`. Declarative markers add page roles and section engagement with no API calls at all, and everything lands in one database your AI agent queries over MCP.

## Install

One tag, in ``, on every page:

```
<script src="https://askbowtie.com/bowtie.js" data-token="your-claim-token" async></script>
```

`data-token` shown here is a placeholder: yours is unique to your site and is how askbowtie confirms the site is yours, so copy the real line from [your dashboard](/app/). `async`, not `defer`: the tracker catches errors that happen while the page is still parsing. Never version the URL. The domain must be registered at askbowtie.com, otherwise the tracker disables itself.

Copy-paste steps for WordPress, Shopify, Google Tag Manager, and Cloudflare: [the full install guide &rsaquo;](/docs/install/)

Conversions that happen on your server, not in a browser? Post the page view from your backend too and attribution becomes exact: [server-side sessions &rsaquo;](/docs/server-side/)

## Tracked automatically

*Signals captured with zero configuration*

| Signal | Event types |
| --- | --- |
| Page views, sessions | `page_load` `session_start` `page_hidden` |
| Clicks | `click` `external_link_click` `rage_click` |
| Forms | `form_submit` |
| Errors | `console_error` `resource_error` `network_error` `csp_violation` |
| Performance | `web_vitals` (LCP, CLS, INP, TTFB) |
| Engagement | `page_hidden` carries engaged time and scroll depth: the furthest scroll % reached (0–100), surfaced per page in analytics |

UTM parameters and ad click IDs (gclid, msclkid, fbclid) are captured on landing and survive internal navigation. No cookies. Input values are never read; PII in query strings is redacted before sending.

## Public API

The API lives at `window.bowtie` once the script loads. The script is async, so guard calls that might run before load: `window.bowtie?.track(...)`. Inside user-action handlers (clicks, submits) the tracker is always ready.

### Custom events

```
bowtie.track('signup_started', { source: 'homepage_cta' });
bowtie.track('plan_selected', { plan: 'agency', seats: 5 });
```

First argument: any string you choose. Second: a flat object of context, stored with the event and queryable later.

### Conversions and revenue

```
bowtie.converted('purchase', { value: 99.00, currency: 'USD', transaction_id: 'ord_1842' });
bowtie.converted('lead_captured', { source: 'contact_form' });
bowtie.converted('trial_started');
```

| Field | Type | Notes |
| --- | --- | --- |
| `value` | number | Revenue or goal value. Optional. |
| `currency` | string | ISO code, default `USD` |
| `transaction_id` | string | Dedupe key: the same conversion never counts twice |
| `pixels` | boolean / string | Also fire to connected ad platforms. `true` = primary Google Ads label, `'secondary'` = secondary label. Default off. |
| `email`, `phone` | string | Enhanced conversions, hashed by gtag before leaving the page. Only used when `pixels` fires. |

Any other keys you pass are stored with the event.

### Visitor identity (cross-session funnels)

```
bowtie.identify('u_8f3a91');   // after signup/login: your user id or a hashed email
bowtie.identify(null);         // on logout: forget the visitor
```

By default askbowtie is cookieless: sessions don't connect across visits, so a funnel that completes days later (signup today, purchase from your reminder email next week) reads as incomplete. `identify()` fixes that with **your** identifier: it persists in localStorage, rides every event, and visitor-scoped funnels stitch the return visit to the original one.

Zero-code alternative: add `?ab_uid=u_8f3a91` to links in your return emails. The tracker picks it up on landing, same effect.

    The identifier is yours, which means the consent story is yours too:
    calling `identify()` makes cross-session tracking happen under your user
    relationship. Sites that never call it keep the cookieless, no-banner default. Prefer
    an internal id or a hashed email over raw PII.

### Application errors

```
bowtie.error('payment_declined', { reason: 'insufficient_funds', amount: 99.00 });
bowtie.error('validation_failed', { field: 'email' });
```

For errors your own code catches. Uncaught JS errors, failed resources, and failed requests are already captured automatically.

### Guardrails

```
bowtie.guardrail('rate_limited', { endpoint: '/api/checkout', limit: '10/min' });
bowtie.guardrail('geo_blocked', { country: 'XX' });
```

Intentional blocks that are not failures: rate limits, quota, access denied, feature flags. Kept separate from errors so your error rate stays honest.

### Utilities

```
bowtie.getSessionId()  // current session id: REQUIRED for server-side conversions (Rule 2 below)
bowtie.flush()         // send the queue now, before a hard navigation
bowtie.debug(true)     // tag this tab's events debug:true, persists across pages
```

    Migrating from itbroke.dev? `window.itbroke` is an alias of
    `window.bowtie`. Existing inline calls keep working unchanged.

## Page roles (tags)

Declare what a page *is*, in the page itself:

```
<body data-askbowtie-tag="editorial">
<!-- or -->
<meta name="askbowtie:tag" content="editorial">
```

This is a **tag, not an event**: no API call, nothing to fire. bowtie reads the marker on every crawl (served HTML, so even zero-traffic pages classify) and on every `page_load`. Analytics then group and filter by role instead of slug: "how does editorial convert vs commercial" becomes one question, not a scan of every page.

| Rule | Detail |
| --- | --- |
| Value | A free lowercase slug you choose: `editorial`, `commercial`, `conversion`, `lander`… Keep roles coarse: 3-6 that mean something. |
| Read on | Every crawl and every `page_load`. Ship a new page with the marker and it classifies itself, no reminders. |
| Precedence | manual > declared > suggested. An explicit tag (the `tag_pages` tool) wins over the page's marker; auto-detection only *suggests* roles for untagged pages and never overwrites anything. |
| Where it surfaces | `get_page_types` (role rollup + untyped bucket + suggestions), `get_top_pages {type}`, and the Type column and filter on the app's analytics page. |

Put the marker in your layout template once and every page using it carries the role forever. For legacy sections you won't re-template, your agent can bulk-assign with `tag_pages {tag, match: '/blog/'}` over MCP.

## Sections (`data-askbowtie-section`)

Name the regions of a page, and analytics can split that page's viewers by which section they clicked: share of viewers per section, plus how each group converted afterwards. "What % of this page's traffic uses the nav vs a CTA" becomes one question.

```
<section data-askbowtie-section="hero">
<nav data-askbowtie-section="nav:header">
<a data-askbowtie-section="cta:start-quiz" href="/quiz">
```

Like page roles, this is a **marker, not an event**: no API call, nothing to fire. The tracker already records every click; with markers present, each click resolves to the nearest ancestor carrying the attribute at click time.

| Rule | Detail |
| --- | --- |
| Value | `section` or `section:sublabel`, each a lowercase slug: `hero`, `content`, `nav:header`, `cta:buy`… |
| Placement | On the section container. Clicks anywhere inside inherit it; a specific element (one CTA link) can carry its own marker, which wins as the nearer ancestor. |
| Retroactive rules | Clicks recorded before markers existed can be classified by a rule mapping an existing click signature to a section (the `manage_sections` tool). Rules apply at query time, so one rule names months of history instantly and clearing it reverts cleanly. A marker always wins over a rule. |
| Where it surfaces | `get_engagement {domain, page, period}` over MCP, and the Engagement block in the app's analytics page detail. Unnamed click signatures are listed as suggestions: proposals only, never auto-applied. |

One property to know: a session can engage several sections, so section shares overlap and do not sum to 100%.

## Server-side events (no browser)

For events your backend is the source of truth for: webhook-confirmed conversions, ad-block-proof lead capture, server-validated purchases. These POST straight to the ingest endpoint from a backend or Cloudflare Worker; no tracker, no browser.

**Rule 1: every event needs `"source": "server"`.**
    It (1) opts the batch out of the browser User-Agent bot filter, which otherwise silently drops
    *any* non-browser sender behind a `200`, and (2) tags the event as
    server-origin so it isn't stored as a browser (`js`) event and merged into your
    client counts.

**Rule 2: `session_id` must be the visitor's REAL
    session id.** Any string is accepted and your conversion is counted either way, so
    getting this wrong never errors. But an id that never appeared on a real visit can't be joined
    to the session that caused it, so askbowtie can never tell you which source, landing page,
    device or country converted. Totals stay right; every conversion breakdown goes empty. On live
    traffic this is binary: a real session id attributed **214 of 214 (100%)**, while
    a synthetic `srv_…` id attributed **0 of 77**.

```
curl -X POST https://askbowtie.com/api/ingest \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "yoursite.com",
    "session_id": "k3f9qz2ab7x",
    "events": [{ "event": "lead_captured", "category": "conversion", "source": "server",
                 "value": 1200, "currency": "USD", "transaction_id": "lead_1842" }]
  }'
```

**Getting to ~100%: capture, persist, echo.** The mistake is trying to obtain the session id *at conversion time*, when there's no browser to ask, so one gets invented. Instead: read `bowtie.getSessionId()` in the browser at the web touch and send it to your backend; store it on the lead or order row (Stripe's `client_reference_id` is purpose-built for this); then have the webhook or batch job read it back and POST under it. Never re-read `getSessionId()` later, even in a browser: sessions roll over after 30 minutes idle, so you'd get a different id than the one that earned the conversion. Only use a synthetic id when there was genuinely no web session at all, such as a phone order.

**Verify both rules; they need different checks.** For rule 1 the response returns a per-source count of what was actually persisted: `{ "stored": { "server": 1 } }`. Assert `stored.server` equals the number you sent; `events_received` only proves the POST was accepted, never how the event stored. Seeing `stored.js` means it was accepted but mis-tagged. Rule 2 is *invisible* to `stored`, so check `get_traffic` → `attribution.unattributed_rate`: under ~5% is wired correctly, 20–60% means some paths capture the id and others invent it, and over 90% means no path passes a real one.

**Don't block your response:** there's no server-side `sendBeacon`, so fire-and-forget the POST (e.g. `ctx.waitUntil(fetch(...))` in a Worker) so your user reply returns immediately. Keep a `transaction_id` so retries never double-count.

Full spec, field table, and response contract: [events.md → Server-side events](/docs/events.md#server-side-events-no-browser).

## Funnels (flows)

Define the path a visitor should take once (pages and events, in order) and every funnel question becomes one call. Define it here in the app ([/app/flows](/app/flows)) or let your agent do it conversationally (`define_flow` over MCP).

```
define_flow('quiz funnel', steps: [
  { kind: 'page',  match: '/quiz' },
  { kind: 'event', match: 'quiz_step_2' },
  { kind: 'event', match: 'signup' }
])
```

Evaluation is order-aware (step 2 only counts after step 1), shows fall-off and median time per step, incidents on each step's page, a baseline window, and flags steps that are too new to compare. Periods include `@last-deploy`: "did the thing I just shipped help?"

### Variants and splits

```
// One event name, many variants. Filter by payload field:
{ kind: 'event', match: 'popup_shown', where: [{ field: 'popup_id', op: 'eq', value: 'exit-intent' }] }

// Same funnel, paid vs organic (first-touch, mutually exclusive):
get_flow('quiz funnel', segment: [{ field: 'tracking.utm_medium', op: 'eq', value: 'cpc' }])
get_flow('quiz funnel', segment: [{ field: 'tracking.utm_medium', op: 'not_exists' }])
```

Ops: `eq, neq, in, contains, exists, not_exists, gt, gte, lt, lte`. A step's `match` also takes an array for any-of matching.

### Return visits

Funnels are session-scoped by default. If your visitors complete later (purchase from a reminder email days after signing up), pass `scope: 'visitor'`. It stitches sessions through [visitor identity](#identify).

## Querying the data

Everything above is queryable by an AI agent through MCP:

```
claude mcp add --transport http askbowtie https://askbowtie.com/mcp --scope user \
  --header "Authorization: Bearer YOUR_TOKEN"
```

New to the server, ask your agent to call `playbook` first; it returns the guided map of what to ask and which tool answers it. The full 47-tool surface:

- **Start here:** `list_domains`, `register_domain` (add a new domain straight from the agent, returns the tag), `playbook`
- **Traffic & pages:** `get_summary`, `get_traffic`, `get_top_pages`, `get_page`, `get_referrers`, `get_page_scores`, `get_page_types`, `tag_pages`, `get_engagement`, `manage_sections`, `get_trend` (up to a full year, where the rest top out at 30 days)
- **Conversions & performance:** `get_conversions`, `get_landing_pages` (which entry page led to the conversion), `get_performance`, `manage_conversion_upload`
- **Errors & uptime:** `get_incidents`, `get_incident_detail`, `get_alerts`
- **Deterministic crawl:** `audit_page` (one page), `crawl_site` (whole site), `audit_lander` (paid landing page, scored 0–100), `lint_sections` (pre-deploy check that a page's clickable elements are distinguishable): binary truth, not session-inferred
- **Search & ads:** `get_search` (Google Search Console), `get_ads`, `get_ads_report` (search terms, Quality Score components, impression share), `get_paid_performance` (spend joined to the signups it bought)
- **Act on Google Ads:** `manage_ads`: pause/enable, set budgets and bids, add negatives and sitelinks, create campaigns and ads. Owner-gated; every change previews before it applies, and created campaigns start paused.
- **Funnels:** `define_flow`, `list_flows`, `get_flow`, `delete_flow`, `flow_alert`
- **Custom events & experiments:** `get_events` (count any custom event by any property it carries), `get_actions` (what visitors click, rolled up across pages), `get_ab_tests` (per-arm sessions, conversions and the change against control)
- **Actions & ops:** `send_alert`, `schedule_report`, `manage_watch` (standing watches evaluated hourly between your sessions), `add_note`, `update_note`, `get_notes`, `get_mcp_usage` (which tools your own agents actually reach for), `report_feedback`, `ask_question` (ask the askbowtie team something the tools and docs do not answer)

Custom events, revenue, errors, and guardrails come back with their data fields intact, and answers are joined across silos at read time.

### Connect Google Search Console & Google Ads

The search and ads tools (`get_search`, `get_ads`, `get_ads_report`, `get_paid_performance`, `manage_ads`, `manage_conversion_upload`) read from Google, so they need a one-time link: **Settings → Connectors** in the app, one OAuth consent per product. Search Console is linked read-only. Google Ads has no read-only scope, so its link grants read and write; management stays conservative regardless: `manage_ads` is limited to the domain's owners and admins, every change is previewed before it applies, budget changes and campaign creation sit behind a separate switch on the connector card, and campaigns it creates start paused.

## For AI agents (machine reference)

Compact contract for an LLM using the askbowtie MCP server. Everything above is prose; the full plaintext reference lives at [/docs/events.md](/docs/events.md).

### Connect

```
claude mcp add --transport http askbowtie https://askbowtie.com/mcp --scope user \
  --header "Authorization: Bearer YOUR_TOKEN"
```

First call on a new server: `playbook`. It returns the guided map of what to ask and which tool answers it.

### Tools by category (47)

```
# Start here
list_domains · register_domain · playbook

# Traffic & pages
get_summary · get_traffic · get_top_pages · get_page · get_referrers
get_page_scores · get_page_types · tag_pages · get_engagement · manage_sections
get_trend

# Conversions & performance
get_conversions · get_landing_pages · get_performance · manage_conversion_upload

# Errors & uptime
get_incidents · get_incident_detail · get_alerts

# Deterministic crawl
audit_page · crawl_site · audit_lander · lint_sections

# Search & ads (need the one-time Settings → Connectors link)
get_search · get_ads · get_ads_report · get_paid_performance · manage_ads

# Funnels
define_flow · list_flows · get_flow · delete_flow · flow_alert

# Custom events & experiments
get_events · get_actions · get_ab_tests

# Actions & ops
send_alert · schedule_report · manage_watch · add_note · update_note · get_notes
get_mcp_usage · report_feedback · ask_question
```

### Rules

- Server-side ingest: every event in a `POST /api/ingest` batch must carry `"source": "server"`. Verify with `stored.server` in the response, never `events_received`.
- Page role precedence: manual (`tag_pages`) > declared (`data-askbowtie-tag`) > suggested. Suggestions are proposals only, never auto-applied.
- Section of a click: marker > first matching `manage_sections` rule by priority > unknown. Section shares overlap across sessions and do not sum to 100%.
- Funnels are session-scoped by default; pass `scope: 'visitor'` to stitch return visits via `bowtie.identify()`.
- `manage_ads` is limited to owners and admins. Every change previews before it applies, and campaigns it creates start paused.

To learn how to give your AI agent memory across sessions using notes and the MCP, see [how I give my AI agent memory between sessions](/guides/ai-agent-memory/).
