01 —Introduction
The Lead Intent Agent (LIA) is a single-file JavaScript bundle (lia.js) designed to be embedded in any web property. It continuously observes visitor behaviour, enriches each event with device, locale, and acquisition context, and streams structured event payloads to a backend API.
LIA was built around three core principles:
- Privacy-first — respects DoNotTrack headers, never reads form field values, and sanitises URL query parameters to remove PII before transmission.
- Resilient delivery — events are batched in memory and delivered using
fetch + keepalivewith exponential-backoff retry. Page-exit events usesendBeaconsemantics to survive tab closure. - Zero dependencies — ships as a single IIFE with no external runtime requirements, compatible with all modern browsers and SPA frameworks.
The source tree is split into six ES modules (identity.js, context.js, behaviour.js, suspicion.js, transport.js, tracker.js) orchestrated by agent.js. The build script (build.js) strips ES module syntax and wraps the result in an IIFE, producing lia.js and lia.min.js.
02 —Architecture Overview
The agent follows a pipeline architecture: raw browser signals flow through enrichment stages before being queued in an in-memory event buffer. A timer-based flush loop delivers batches to the API; a session update callback allows the server to push scoring data back into the agent state.
03 —Module Map
LIA is composed of six specialised modules. Each module has a single responsibility and communicates through the agent.js orchestrator.
identity.js
Generates and persists visitor_id (long-lived) and session_id (TTL-based). Falls back gracefully: localStorage → sessionStorage → in-memory.
context.js
Captures device fingerprint, browser/OS family, locale, timezone, network info, and UTM acquisition parameters. Sanitises query strings to remove PII.
behaviour.js
Tracks scroll depth milestones (25/50/75/90/100%), active time, idle detection (30s), visibility changes, and fires engaged_session when both thresholds are met.
suspicion.js
Computes a client-side bot suspicion score (0–100) using WebDriver flags, headless UA hints, zero-screen dimensions, plugin counts, and event timing variance.
transport.js
Manages event batching (max 10/batch, queue cap 100), flush timer (5s), retry logic (3 attempts, exponential backoff), and reliable unload delivery via keepalive fetch.
tracker.js
CTA click detection with booking-platform pattern matching. Form lifecycle tracking (opened → started → field touched → submitted) using IntersectionObserver and event delegation.
04 —Installation
Install LIA by dropping a configuration object and the script tag into your HTML <head>. The agent auto-initialises when the DOM is ready.
<!-- 1. Declare config BEFORE the script tag --> <script> window.LeadIntentConfig = { siteId: 'your-site-id', apiBaseUrl: 'https://api.yourbackend.com', apiKey: 'sk-...', // Optional overrides sessionTtlMinutes: 30, captureForms: true, captureScroll: true, captureClicks: true, respectDoNotTrack: true, debug: false, }; </script> <!-- 2. Load the agent (async is fine) --> <script async src="/lia.js"></script>
If window.LeadIntentConfig is not present when lia.js executes, the agent uses a MutationObserver on <head> to wait for the config object. Declare the config synchronously before the script for fastest initialisation.
SPA / Framework integration
For React, Vue, or Next.js applications, call the API directly after importing the bundle:
// After lia.js has loaded (window.LeadIntentAgent is available) window.LeadIntentAgent.init({ siteId: 'my-site', apiBaseUrl: 'https://api.example.com', apiKey: 'sk-abc123', pageCategoryRules: [ { match: '^/solutions', category: 'services' }, { match: '^/demo', category: 'booking' }, ], });
05 —Configuration Reference
| Key | Type | Default | Description |
|---|---|---|---|
| siteId | string | — | required Unique identifier for the tracked site. Sent on every event. |
| apiBaseUrl | string | — | required Base URL of the backend API. The agent appends /v1/events/batch, /v1/identify, etc. |
| apiKey | string | — | required Sent as X-Api-Key header on all requests. |
| sessionTtlMinutes | number | 30 | Session inactivity timeout in minutes. A new session is created when the TTL expires between page loads. |
| captureForms | boolean | true | Enable form lifecycle tracking. Set to false to disable all form events. |
| captureScroll | boolean | true | Enable scroll depth tracking and active-time measurement. |
| captureClicks | boolean | true | Enable CTA click detection using default and custom selectors. |
| respectDoNotTrack | boolean | true | When true, sets consent_state to essential_only if the browser's DNT header is 1. |
| captureHashRoutes | boolean | false | Listen to hashchange events in addition to pushState. |
| ctaSelectors | string[] | [] | Additional CSS selectors to treat as CTA elements, merged with the built-in list. |
| pageCategoryRules | Rule[] | [] | Array of { match: RegExp, category: string } rules evaluated before built-in heuristics. |
| debug | boolean | false | Enables verbose console.log output and forces fresh identity on every page load (development only). |
06 —Consent & Privacy
LIA ships with first-party consent management hooks. Tracking is fully blocked when consent is 'denied'. A setConsent() method allows integration with any external CMP.
// States: 'unknown' | 'granted' | 'denied' | 'essential_only' // Example: integrate with a cookie consent banner myCMP.onDecision((decision) => { window.LeadIntentAgent.setConsent( decision.analytics ? 'granted' : 'denied' ); }); // Check the current session state at any time const session = window.LeadIntentAgent.getSession(); // → { sessionId, visitorId, leadId, score, segment }
Privacy guardrails built into the agent
- Query strings are sanitised — only UTM params and a known allowlist pass through.
- Form field values are never read — only field type classification (email, phone, company) is tracked.
- DoNotTrack header forces
essential_onlymode whenrespectDoNotTrack: true. - The
reset()method clears all persisted identity from all storage layers.
07 —Identity Module
LIA maintains two levels of identity persistence with automatic storage fallbacks:
Visitor ID
- lia_visitor_id long-lived
Generated once per browser using crypto.randomUUID(). Persists until cleared. Used for cross-session visitor recognition.
Session ID
- lia_session_id session TTL
- lia_session_start timestamp
Refreshed on each interaction. Expires after sessionTtlMinutes of inactivity. A new session emits session_started.
First Touch
- lia_first_touch preserved
Captures UTM params and referrer from the very first visit. Never overwritten — preserved for attribution across all subsequent sessions.
Storage fallback chain
All keys are written to and read from three storage layers in priority order: localStorage → sessionStorage → in-memory object. This ensures the agent functions in privacy-hardened browsers that block persistent storage.
08 —Behaviour Tracker
The behaviour module monitors engagement quality using non-invasive, throttled listeners. It never fires on every raw DOM event; all signals are debounced or milestone-gated.
09 —Transport Layer
The transport module manages all network communication. It batches events to reduce request overhead, retries failed deliveries, and ensures critical events (form submissions, bookings) are flushed immediately.
| Parameter | Value | Notes |
|---|---|---|
| MAX_BATCH_SIZE | 10 | Events per batch flush |
| FLUSH_INTERVAL_MS | 5 000 | Timer-based flush interval |
| MAX_RETRY_ATTEMPTS | 3 | Retries with exponential backoff (500ms, 1s, 2s) |
| MAX_QUEUE_SIZE | 100 | FIFO drop when exceeded (oldest removed) |
Critical events — immediate flush
The following events bypass the batch timer and trigger an immediate flush():
form_submitted · booking_started · identify · session_ended · booking_opened
Unload delivery
On pagehide / beforeunload, queued events are sent via fetch with keepalive: true, which allows the request to complete even after the page is destroyed. This is the modern replacement for navigator.sendBeacon and supports custom headers (required for the API key).
10 —Suspicion Engine
The suspicion module computes a client-side quality score to detect automated traffic. These are weak signals only; server-side enrichment is authoritative. A score ≥ 30 triggers a headless_suspected event.
| Signal | Score | Detection Method |
|---|---|---|
webdriver_true |
+30 | navigator.webdriver === true |
headless_chrome_ua |
+20 | /HeadlessChrome/.test(userAgent) AND no window.chrome |
zero_screen |
+20 | screen.width === 0 && screen.height === 0 |
no_plugins |
+5 | navigator.plugins.length === 0 (non-Firefox) |
no_languages |
+5 | navigator.languages.length === 0 |
permissions_anomaly |
+5 | typeof navigator.permissions === 'undefined' |
Additionally, createEventTimingMonitor() tracks event cadence variance. If standard deviation < 15ms over a 200ms average interval, the cadence is classified as robotic.
11 —Public API
Must be called once before any other method. Typically called automatically via window.LeadIntentConfig. Emits session_started or session_resumed.
- eventNamestringName of the custom event to emit. Sent with full page and context enrichment.
- propertiesobject?Arbitrary key-value properties merged into the
propertiesenvelope.
- payload.emailstring?Lead email address. Sent to
POST /v1/identify. - payload.namestring?Lead full name.
- payload.companystring?Company name.
Returns the backend response including lead_id. Subsequent events will include this lead_id for attribution. Triggers a session refresh call to hydrate score and segment.
Returns { sessionId, visitorId, leadId, score, segment } synchronously. Score and segment are populated after the first API round-trip.
Accepted states: 'granted' | 'denied' | 'essential_only' | 'unknown'. Setting 'denied' stops all event emission immediately.
Removes all keys from localStorage, sessionStorage, and in-memory store. Resets session, visitor, and lead IDs. Stops the flush timer.
12 —Event Catalogue
| Event Name | Type | Key Properties | Description |
|---|---|---|---|
session_started |
lifecycle | visitor_id, session_ttl_minutes |
Fired when a new session is created (TTL expired or first visit) |
session_resumed |
lifecycle | visitor_id |
Fired when an existing session is resumed within its TTL |
page_view |
lifecycle | page_category, is_entry_page, page_view_count |
Fired on every page navigation, including SPA route changes |
route_change |
lifecycle | previous_path, path |
Fired when the SPA router changes the URL (pushState or popstate) |
page_exit |
lifecycle | active_time_seconds, max_scroll_depth |
Fired on page unload; delivered via keepalive fetch |
scroll_depth_reached |
behavioral | depth_percent (25/50/75/90/100) |
Fired once per milestone per page. Throttled to 200ms. |
active_time_30s |
behavioral | active_seconds |
Cumulative active time reached 30 seconds |
active_time_60s |
behavioral | active_seconds |
Cumulative active time reached 60 seconds |
active_time_120s |
behavioral | active_seconds |
Cumulative active time reached 120 seconds |
engaged_session |
intent | active_seconds |
Active ≥ 30s AND scroll ≥ 50% — high-quality engagement signal |
cta_click |
intent | cta_text, cta_id, destination_type |
Click on a CTA element matching configured selectors |
booking_opened |
intent | source, href |
Click on a Calendly / Cal.com / Acuity booking link, or booking page view |
mailto_click |
intent | href |
Click on a mailto: link |
form_opened |
critical | form_id |
Form entered the viewport (30% threshold via IntersectionObserver) |
form_started |
critical | form_id |
First field focused in a form |
field_touched |
critical | form_id, field_type |
A classified field (email/phone/company/budget/message) was focused |
form_submitted |
critical | form_id, elapsed_seconds |
Form submit event fired. Triggers immediate transport flush. |
pricing_view |
intent | page_category |
Auto-emitted when page path matches pricing category |
headless_suspected |
behavioral | signals[], score |
Suspicion score ≥ 30 — automated traffic suspected |