Tracking reference
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.
Install
One tag, in <head>, on every page:
<script src="https://askbowtie.com/bowtie.js" async></script>
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 ›
Tracked automatically
| 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.
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, for support and debugging
bowtie.flush() // send the queue now, before a hard navigation
bowtie.debug(true) // tag this tab's events debug:true, persists across pages
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.
"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.curl -X POST https://askbowtie.com/api/ingest \
-H "Content-Type: application/json" \
-d '{
"domain": "yoursite.com",
"session_id": "srv_8f2c…",
"events": [{ "event": "lead_captured", "category": "conversion", "source": "server",
"value": 1200, "currency": "USD", "transaction_id": "lead_1842" }]
}'
Verify with stored, not events_received. 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.
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.
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)
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.
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 39-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 - 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) — 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 - Actions & ops:
send_alert,schedule_report,add_note,update_note,get_notes,report_feedback
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.
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 (39)
# 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
# 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
# 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
# Actions & ops
send_alert · schedule_report · add_note · update_note · get_notes · report_feedback
Rules
- Server-side ingest: every event in a
POST /api/ingestbatch must carry"source": "server". Verify withstored.serverin the response, neverevents_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_sectionsrule 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 viabowtie.identify(). manage_adsis 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.