JavaScript SDK
Drop-in event capture for Product Analyst AI — ~1.5KB gzipped, no dependencies
Overview
The Product Analyst AI SDK is a tiny JavaScript snippet that streams user behavior from your web app into the agent in real time. It autocaptures pageviews and clicks out of the box, supports custom events via pai.track(), and runs alongside any other analytics tool (Mixpanel, PostHog, Amplitude, Segment) with no conflicts.
Minimum viable setup
Three steps. Everything below this is deeper configuration — you don't need it to get started, and you don't need PostHog, CSV uploads, or any other data source. The SDK is self-sufficient for identity and analytics.
<!-- 1. Load the script -->
<script src="https://productanalyst.ai/sdk.js" data-api-key="ppk_YOUR_KEY"></script>
// 2. Identify the user — email + name populate your users list
pai.identify('user-42', { email: '[email protected]', name: 'Niko' })
// 3. Attach the user to their account — name populates your accounts list
pai.group('acme-corp-123', { name: 'Acme Corp', domain: 'acme.com' })That's it. The SDK now captures every pageview and click automatically, and both your users and accounts lists are populated from the two identity calls above.
Contents
- Install the script
- Identify your users
- Associate users with companies (B2B)
- Send custom events
- Autocapture
- Opt elements out of capture
- Send events from your backend
- Privacy
- API reference
1. Install the script
Drop this in your <head>. Your public key lives in Settings → Integrations → Data Sources and starts with ppk_. It's a write-only key, safe to embed in client-side code.
<script src="https://productanalyst.ai/sdk.js" data-api-key="ppk_YOUR_PUBLIC_KEY"></script>That's it — the SDK is now loaded. It stays inert (no events sent) until you identify a user.
2. Identify your users
Identifying users is required. The SDK does nothing until you call pai.identify(). There is no anonymous tracking — events are only captured for known, logged-in users. Call this as soon as the user logs in.pai.identify('user-42', { email: '[email protected]', name: 'Niko', plan: 'pro' })| Param | Type | Required | Notes |
|---|---|---|---|
userId | string | Yes | Stable user ID from your own database |
traits | object | No | User metadata — email and name populate your users list; everything else lands in JSONB properties |
Use the same user ID that your backend uses. Traits are stored two ways at once:
- Your users list gets a row in
tracked_userskeyed onuserId.emailandnameare stored as top-level columns so they're queryable without JSON digging; everything else is shallow-merged into apropertiesJSONB column. Subsequent calls enrich the row — existing values are never nullified when you omit a trait. - Every subsequent event carries the traits too, so the agent can answer questions like "how do pro users behave compared to free?"without a join.
Calling identify() also fires an initial $pageview for the current page. On logout, call pai.reset() to clear the user and return the SDK to its inert state.
3. Associate users with companies (B2B)
If your product is used by teams, workspaces, or organizations, tell the SDK which company the current user belongs to. This unlocks account-level analytics — questions like "which accounts show churn risk?" or "analyze behavior inside Acme Corp."
pai.identify('user-42', { email: '[email protected]', name: 'Niko' })
pai.group('acme-corp-123', { name: 'Acme Corp', domain: 'acme.com', plan: 'enterprise' })| Param | Type | Required | Notes |
|---|---|---|---|
accountId | string | Yes | Stable account/org identifier from your app. Pass null to clear. |
traits | object | No | Account metadata — name and domain populate your accounts list; everything else lands in JSONB properties |
Which ID should I use? Whatever ID your own database uses for organizations, workspaces, or teams. The exact shape doesn't matter as long as it's stable across sessions.
Where traits land. Each pai.group() call upserts a row in tracked_accounts keyed on accountId.name and domain are stored as top-level columns (queryable directly); anything else — plan, mrr,signup_date, whatever — is shallow-merged into a JSONBproperties column. Subsequent calls enrich the row; existing values are never nullified when you omit a trait.
No separate data source needed. Company-level metadata goes through this one call. You don't need to stream it from PostHog, upload a CSV, or wire up a CRM sync to get name / domain / planinto your accounts list.
Consumer app? No companies? Skip this step entirely. pai.group() is optional — if you never call it, events are still linked to individual users and everything else works as expected.
Switching accounts. Call pai.group() again with the new ID whenever the user changes workspace. pai.reset() clears both the user and the account on logout.
4. Send custom events
Use pai.track() for anything autocapture can't see — backend signals, multi-step flows, business logic.
pai.track('upgrade_clicked', { plan: 'pro', annual: true })| Param | Type | Required | Notes |
|---|---|---|---|
eventName | string | Yes | Stored as-is (no transformation) |
properties | object | No | Arbitrary key-value pairs passed to alert evaluators |
Custom event names are kept exactly as you pass them. Autocaptured events ($pageview, $click) are renamed before storage — see below.
5. Autocapture
Out of the box, the SDK captures:
- Pageviews — initial load and SPA navigation (
pushState,replaceState,popstate) - Clicks —
<a>,<button>,[role="button"], and any element withcursor: pointer
When a nested element is clicked (e.g. a <span> inside a <button>), the SDK walks up to the nearest interactive ancestor and captures that.
Human-readable event names
Raw autocapture events are automatically transformed into readable names before storage, so you don't have to grep through thousands of $click rows to find what you need:
| Raw event | Stored as |
|---|---|
$pageview on /settings/integrations | viewed_settings_integrations |
$pageview on / | viewed_home |
$click "Manage" under "PostHog" heading on /settings | clicked_posthog_manage_on_settings |
$click "Save" on /settings/profile | clicked_save_on_settings_profile |
Click names pull context from the nearest heading or aria-label, so generic "Save" or "Manage" buttons are disambiguated automatically.
Properties captured
Pageviews:
| Property | Example |
|---|---|
url | https://app.acme.com/dashboard |
path | /dashboard |
title | Dashboard — Acme |
referrer | https://app.acme.com/settings |
Query parameters are stripped by default to avoid leaking tokens or PII.
Clicks:
| Property | Example |
|---|---|
tag | button |
text | Upgrade Plan (truncated to 255 chars) |
href | /pricing (links only) |
id | upgrade-btn (if present) |
classes | btn btn-primary (if present) |
path | /settings/integrations |
context | PostHog (nearest heading or aria-label) |
Text from <input>, <textarea>, and password fields is never captured.
6. Opt elements out of capture
Add the class pai-no-capture to any element to exclude it and all its descendants from autocapture:
<div class="pai-no-capture">
<!-- Nothing inside here will be captured -->
<button>This click is not tracked</button>
</div>For a full kill-switch, just don't call pai.identify() — the SDK stays inert and sends nothing.
7. Send events from your backend
The SDK POSTs batched events to the ingest endpoint. You can also hit it directly from your own server if you prefer not to use the SDK:
POST https://productanalyst.ai/api/events/ingest
Authorization: Bearer ppk_YOUR_PUBLIC_KEYPayload format
{
"events": [
{
"event": "upgrade_clicked",
"userId": "user-42",
"accountId": "acme-corp-123",
"timestamp": "2026-01-15T10:00:00Z",
"properties": { "plan": "pro", "annual": true },
"traits": { "plan": "pro" },
"id": "a1b2c3d4-e5f6-..."
}
]
}| Field | Type | Required | Notes |
|---|---|---|---|
event | string | Yes | Event name |
userId | string | Yes | User identifier |
accountId | string | No | B2B account/org ID (set via pai.group() from the SDK) |
timestamp | ISO 8601 | No | Defaults to server receive time |
properties | object | No | Event-specific data |
traits | object | No | User properties merged from identify() |
id | string | No | Client-generated UUID for deduplication — duplicates are silently ignored, so retries are safe |
Maximum 100 events per request. From the SDK, events are flushed every 5 seconds or when the queue hits 10 events, whichever comes first. On page unload, remaining events are flushed via fetch with keepalive: true.
Internal metadata events
Two reserved event names, $identify and $group, travel through the same batch endpoint but are not stored in your event stream. The server diverts them into upserts against your users and accounts lists:
| Event | Source | Routed to | Notes |
|---|---|---|---|
$identify | Emitted by pai.identify() | tracked_users upsert | Never appears in company_events. Payload = the trait object. |
$group | Emitted by pai.group() | tracked_accounts upsert | Never appears in company_events. Payload = the trait object. |
If you're hitting the ingest endpoint directly from your own backend, you can post $identify / $group events with the same shape as regular events — the properties field carries the trait payload, and for $group, accountId is required.
Privacy
- No cookies. The SDK stores nothing in the browser.
- No anonymous tracking. Events are only sent after
identify(). - Query parameters stripped from captured URLs.
- Input text never captured — inputs, textareas, and password fields are always ignored.
pai-no-captureto exclude sensitive UI regions.
Compatibility
Works in all modern browsers (Chrome, Firefox, Safari, Edge). Requires the fetch API. No dependencies. Runs alongside Mixpanel, PostHog, Amplitude, Segment, and any other analytics tool with no conflicts — it uses the pai global namespace.
API reference
| Method | Purpose |
|---|---|
pai.identify(userId, traits?) | Activates the SDK, links events to a user, upserts the row in your users list (email + name as top-level columns, everything else into JSONB properties), and fires an initial $pageview. Required. |
pai.group(accountId, traits?) | Associates the current user with a B2B account and upserts the row in your accounts list (name + domain as top-level columns, everything else into JSONB properties). Pass null as the first argument to clear. Optional. |
pai.track(eventName, properties?) | Send a custom event with arbitrary properties. |
pai.reset() | Clear the user and account, flush pending events, return SDK to its inert state. Call on logout. |