This is the full developer documentation for WriteTrack
# WriteTrack Documentation
> Client-side keystroke dynamics capture and behavioral analysis
WriteTrack is a TypeScript SDK that captures and analyzes typing behavior. It records behavioral signals like timing, rhythm, corrections, and clipboard interactions from any text input element.
[Get Started ](/docs/quickstart/)Install and integrate WriteTrack in under 5 minutes
[Download Full Docs ](/llms-full.txt)Get all documentation as a single file for LLMs
## Key Features
[Section titled “Key Features”](#key-features)
* **100% Client-side** — All capture and analysis happens in the browser; no data is sent anywhere unless you configure [output sinks](/docs/output-sinks/)
* **Framework agnostic** — Works with vanilla JS, React, Vue, Svelte, or any frontend framework
* **Lightweight** — \~115KB gzipped, zero runtime dependencies
* **Detailed Behavioural Data** — Captures timing, rhythm, corrections, clipboard usage, and more
## Quick Example
[Section titled “Quick Example”](#quick-example)
```typescript
import { WriteTrack, webhook } from 'writetrack';
const textarea =
document.querySelector('#response-field')!;
const tracker = new WriteTrack({ target: textarea });
// Session data is POSTed to your endpoint when getData() is called
tracker.pipe(webhook({ url: 'https://api.example.com/writetrack' }));
tracker.start();
// ... user types ...
// Retrieve session data and dispatch to all registered sinks
const data = tracker.getData();
```
## How It Works
[Section titled “How It Works”](#how-it-works)
WriteTrack instruments your text inputs and captures behavioral signals:
1. **Timing Patterns** — Dwell time (key hold duration), flight time (between keystrokes), rhythm consistency
2. **Correction Behavior** — Backspaces, deletions, arrow key navigation, revision patterns
3. **Clipboard Interactions** — Paste events, selection patterns, copy/cut operations
4. **Session Dynamics** — Typing bursts, natural pauses, fatigue progression
5. **Authenticity Analysis** — Optional WASM-powered analysis scores each session across six categories: content origin, timing authenticity, session continuity, physical plausibility, revision behavior, and temporal patterns
## Browser Compatibility
[Section titled “Browser Compatibility”](#browser-compatibility)
* **Desktop**: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
* **Mobile**: iOS Safari 14+, Android Chrome 90+, Mobile Firefox 88+
## Next Steps
[Section titled “Next Steps”](#next-steps)
* [Quickstart](/docs/quickstart/) — Install and integrate in under 5 minutes
* [Licensing](/docs/license/) — License keys for production use
* [API Reference](/docs/api-reference/) — Complete method reference
# Analysis
> Run typing authenticity analysis on captured sessions
WriteTrack can analyze captured typing sessions to assess authenticity. Analysis runs in a compiled WebAssembly module — it loads on demand and returns structured results across six behavioral categories.
## Browser-Side Analysis
[Section titled “Browser-Side Analysis”](#browser-side-analysis)
Call `getAnalysis()` on a tracker after the user finishes typing:
```typescript
import { WriteTrack, formatIndicator } from 'writetrack';
const tracker = new WriteTrack({ target: textarea });
tracker.start();
// ... user types ...
tracker.stop();
const analysis = await tracker.getAnalysis();
if (analysis) {
console.log(analysis.contentOrigin.indicator.code);
// → "WT-100"
console.log(formatIndicator(analysis.contentOrigin.indicator));
// → "All text was typed directly"
console.log(formatIndicator(analysis.physicalPlausibility.indicator));
// → "All keystroke timing is physically plausible"
}
```
`getAnalysis()` returns a [`SessionAnalysis`](/docs/api-reference/#sessionanalysis) object, or `null` if the WASM module fails to load. The first call triggers a lazy load of the WASM binary (\~100KB); subsequent calls reuse the cached module.
The `sufficientData` field on the result tells you whether enough events were captured for meaningful analysis. Short sessions or sessions with very few keystrokes may return `sufficientData: false` — the metrics will still be populated, but treat them with caution.
### Session Reports
[Section titled “Session Reports”](#session-reports)
`getSessionReport()` bundles raw data and analysis into a single object — useful when you want to send everything to your server in one payload:
```typescript
const report = await tracker.getSessionReport();
// { data: WriteTrackDataSchema, analysis: SessionAnalysis | null }
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(report),
});
```
## Server-Side Analysis
[Section titled “Server-Side Analysis”](#server-side-analysis)
The `analyzeEvents()` function analyzes a `WriteTrackDataSchema` object without needing a DOM element. Use this when you’ve already collected session data and want to run analysis on your server or in a background job:
```typescript
import { analyzeEvents } from 'writetrack';
// data is a WriteTrackDataSchema object from getData()
const analysis = await analyzeEvents(data);
if (analysis) {
const { contentOrigin, timingAuthenticity, physicalPlausibility } = analysis;
// Inspect category metrics and indicators
}
```
Note
Server-side analysis requires a Node.js environment with WebAssembly support (Node 16+). The `locale` field will be empty since `navigator.language` is unavailable; set it on the returned object if you have locale context.
## Analysis Categories
[Section titled “Analysis Categories”](#analysis-categories)
Each category contains an `indicator` (an [`IndicatorOutput`](/docs/api-reference/#indicatoroutput) with a machine-readable `code` and numeric `params`) and a `metrics` object with the underlying data. Use [`formatIndicator()`](/docs/api-reference/#formatindicator) to convert an indicator to a plain-English string, or make your own decisions from the raw metrics.
### Content Origin
[Section titled “Content Origin”](#content-origin)
Where the text came from: typed, pasted, or autocompleted.
| Metric | Type | Description |
| ------------------------------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `charactersByOrigin.typed` | `number` | Ratio of directly typed characters (0–1) |
| `charactersByOrigin.pasted` | `number` | Ratio of pasted characters (0–1) |
| `charactersByOrigin.autocompleted` | `number` | Ratio of autocompleted characters (0–1) |
| `pasteEvents` | `array` | Each paste with `timestamp`, `characterCount`, `source` (`internal` / `external` / `unknown`), `precedingTabAwayDuration` (ms away before pasting), `contentMatchedDocument`, `editsInRegion` (keydowns within paste region within 60s), `firstEditDelayMs` (ms to first edit), and `reworkRatio` (edits / pasted chars, 0.0 = untouched) |
| `pasteClassification.internalPasteRatio` | `number` | Ratio of chars from internal paste (cut-paste restructuring) to total chars (0–1) |
| `pasteClassification.externalPasteRatio` | `number` | Ratio of chars from external paste (outside content) to total chars (0–1) |
| `pasteClassification.unknownPasteRatio` | `number` | Ratio of chars from unknown-source paste to total chars (0–1) |
| `pasteEditSummary.pasteRetentionRate` | `number` | Fraction of pastes with zero edits in their region within 60 seconds (0–1) |
| `pasteEditSummary.meanModificationDelayMs` | `number` | Average delay (ms) from paste to first edit, across pastes that were edited |
| `pasteEditSummary.pasteToTypeRatio` | `number` | Total pasted characters / total typed characters |
| `pasteEditSummary.meanReworkRatio` | `number` | Average rework ratio across all pastes (0 = no editing, >1 = heavy rework) |
| `pasteEditSummary.pasteLengthMean` | `number` | Mean paste size in characters |
| `pasteEditSummary.pasteLengthStd` | `number` | Standard deviation of paste sizes |
| `pasteEditSummary.pasteLengthMin` | `number` | Smallest paste size in characters |
| `pasteEditSummary.pasteLengthMax` | `number` | Largest paste size in characters |
| `contentTimeline` | `MultiTimeSeries` | Character origin ratios over time |
Content origin has two indicators: a primary `indicator` for overall content source (WT-100 through WT-104) and a `pasteReworkIndicator` for paste editing behavior (WT-105 through WT-107).
Example primary indicators:
* *“All text was typed directly”*
* *“62% of text was pasted from external sources”*
* *“35% of text was inserted via autocomplete”*
Example paste rework indicators:
* *“Unmodified external content detected — pasted text was not edited”* (WT-105)
* *“40% of pasted content was subsequently reworked”* (WT-106, positive authenticity signal)
* *“75% paste ratio with 90% left unmodified”* (WT-107)
### Timing Authenticity
[Section titled “Timing Authenticity”](#timing-authenticity)
Whether keystroke timing looks human or mechanical.
| Metric | Type | Description |
| ------------------------ | ----------------- | ------------------------------------------------------------------------------------------------ |
| `dwellTimeDistribution` | `object` | Mean, coefficient of variation (CV), histogram, and human reference range for key hold durations |
| `flightTimeDistribution` | `object` | Same structure for intervals between keystrokes |
| `periodicityScore` | `number` | How periodic the keystroke rhythm is (0–1, higher = more periodic) |
| `entropy` | `number` | Shannon entropy of timing intervals |
| `timingOverTime` | `MultiTimeSeries` | Timing metrics across the session |
Example indicators:
* *“Timing variability is within normal range”*
* *“Keystroke timing is mechanically uniform”*
* *“Highly periodic keystroke pattern detected”*
### Session Continuity
[Section titled “Session Continuity”](#session-continuity)
Behavioral consistency throughout the session — detects abrupt rhythm shifts and tab-away patterns.
| Metric | Type | Description |
| ------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `changePoints` | `array` | Points where typing behavior shifted abruptly. Each has `timestamp`, `metric` (which metric changed), `valueBefore`, `valueAfter`, and `magnitude` |
| `tabAwayEvents` | `array` | Each tab-away with `leftAt`, `returnedAt`, `duration` (ms), and `activityAfterReturn` (`typing`, `paste`, or `idle`) |
| `segmentComparison` | `MultiTimeSeries` | Behavioral metrics compared across session segments |
Example indicators:
* *“Consistent behavior throughout session”*
* *“Typing rhythm changed abruptly at 45s (magnitude 3.2)”*
* *“7 tab-away events detected during session”*
### Physical Plausibility
[Section titled “Physical Plausibility”](#physical-plausibility)
Whether the events could have been produced by a physical keyboard.
| Metric | Type | Description |
| --------------------------- | -------- | ------------------------------------------------------------------------------------------------------ |
| `impossibleSequences` | `array` | Keystroke pairs faster than humanly possible. Each has `timestamp`, `durationMs`, and `humanMinimumMs` |
| `syntheticEventRatio` | `number` | Ratio of events with `isTrusted: false` (0–1) |
| `mouseActivityDuringTyping` | `object` | `periodsWithMouseActivity` (ratio 0–1) and `longestGapWithoutMouse` (ms) |
| `zeroLatencyEventCount` | `number` | Events with zero milliseconds between them |
| `unmatchedKeydownCount` | `number` | Keydowns without a corresponding keyup |
Example indicators:
* *“All keystroke timing is physically plausible”*
* *“42% of events are synthetic (untrusted)”*
* *“83 keydown events without matching keyup detected”*
### Revision Behavior
[Section titled “Revision Behavior”](#revision-behavior)
How the author corrected and revised their work.
| Metric | Type | Description |
| -------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `correctionRate` | `number` | Ratio of correction keystrokes to total keystrokes (0–1) |
| `correctionCount` | `number` | Total correction keystrokes |
| `navigationCount` | `number` | Arrow key navigation events |
| `undoRedoCount` | `number` | Undo/redo operations |
| `editPatterns` | `array` | Categorized edit sequences (`select-delete-type`, `select-paste`, `paste-only`, `select-edit`, `multi-step`) with counts |
| `correctionTimeline` | `TimeSeries` | Correction rate over time |
| `revisionDepth` | `object` | Max/average depth and count of regions edited multiple times |
| `proportionBehindFrontier` | `number` | Ratio of keydowns behind the text frontier (0–1). Higher values indicate revision/composition vs linear transcription |
| `substitutedWordsCount` | `number` | Count of word-level select-then-replace events (selection >= 2 chars followed by typing or paste) |
| `jumpToEditFrequency` | `number` | Ratio of non-local cursor jumps (>= 5 chars) that precede an edit action (0–1) |
| `jumpDistanceSd` | `number` | Standard deviation of cursor travel distances during non-local jumps. High = structural revision; low = clustered typo fixes |
| `revisionAtPriorRatio` | `number` | Ratio of corrections at a prior position (behind the frontier) to total corrections (0–1) |
| `revisionAtFrontierRatio` | `number` | Ratio of corrections at the point of inscription (at the frontier) to total corrections (0–1) |
Example indicators:
* *“Normal correction rate (4.2%)”*
* *“Zero corrections across session”*
* *“Very low correction rate (0.3%)“*
### Temporal Patterns
[Section titled “Temporal Patterns”](#temporal-patterns)
Typing speed changes, fatigue, warmup, and burst patterns over the session.
| Metric | Type | Description |
| ------------------- | ------------ | ------------------------------------------------------------------------------- |
| `sessionDurationMs` | `number` | Total session length |
| `speedTimeline` | `TimeSeries` | Typing speed over time |
| `fatigueRatio` | `number` | Typing speed trend across the session (< 1 = slowing down) |
| `warmupRatio` | `number` | Initial typing speed relative to overall (< 1 = started slow) |
| `pauseDistribution` | `object` | Histogram, count, and mean duration of pauses |
| `burstPattern` | `object` | Average burst length, speed, ratio of burst typing to total, duration SD and CV |
Example indicators:
* *“Temporal pattern within normal range”*
* *“Natural warmup and fatigue pattern detected”*
* *“No speed variation over 8-minute session”*
## Session Timeline
[Section titled “Session Timeline”](#session-timeline)
The analysis includes a segmented timeline classifying each period of the session:
```typescript
analysis.sessionTimeline.forEach((segment) => {
console.log(`${segment.type}: ${segment.startTime}ms – ${segment.endTime}ms`);
});
// typing: 0ms – 3200ms
// pause: 3200ms – 5100ms
// typing: 5100ms – 8400ms
// paste: 8400ms – 8420ms
// typing: 8420ms – 12000ms
```
Segment types: `typing`, `paste`, `pause`, `tabAway`, `autocomplete`, `navigating`.
## WASM Loading
[Section titled “WASM Loading”](#wasm-loading)
The WASM binary loads lazily on the first `getAnalysis()` call and is cached for the lifetime of the page. Path resolution is automatic — in the browser, the binary is resolved relative to the bundled JS; in Node.js, it’s resolved relative to the installed package. You don’t need to configure anything.
You can check readiness with the `wasmReady` property:
```typescript
console.log(tracker.wasmReady); // false (not loaded yet)
await tracker.getAnalysis();
console.log(tracker.wasmReady); // true (loaded and cached)
```
If you need to serve the WASM binary from a non-standard location (e.g., a separate CDN domain), pass `wasmUrl`:
```typescript
const tracker = new WriteTrack({
target: textarea,
wasmUrl: '/assets/writetrack.wasm',
});
```
Note
The WASM module is cached globally after the first load. If you create multiple tracker instances, only the first instance’s `wasmUrl` takes effect.
Caution
If the WASM module fails to load (network error, unsupported environment), `getAnalysis()` returns `null` and `wasmReady` remains `false`. Your application should handle this gracefully.
## Next Steps
[Section titled “Next Steps”](#next-steps)
* [Verification](/docs/verification/) — Verify that analysis output is authentic and untampered
* [API Reference](/docs/api-reference/) — Full method signatures and type definitions
* [Output Sinks](/docs/output-sinks/) — Route session data to your backend
* [Privacy & Security](/docs/privacy/) — What WriteTrack collects and doesn’t collect
# Analysis Charts
> Visualizing WriteTrack Session Analysis
Analysis charts consume a [`SessionAnalysis`](/docs/api-reference/#sessionanalysis) object from `getAnalysis()`. They require the WASM analysis pipeline.
## Usage
[Section titled “Usage”](#usage)
All analysis charts follow the same pattern — import, register once, then pass analysis data:
```typescript
import { SpeedTimeline } from 'writetrack/viz';
SpeedTimeline.register(); // call once per component
const analysis = await tracker.getAnalysis();
document.querySelector('wt-speed-timeline').setData(analysis);
```
## Speed Timeline ``
[Section titled “Speed Timeline \”](#speed-timeline-wt-speed-timeline)
Line + area chart showing typing speed (CPM) over time from `temporalPatterns.metrics.speedTimeline`. Recommended size: `width: 100%; height: 200px`.
## Rhythm Heatmap ``
[Section titled “Rhythm Heatmap \”](#rhythm-heatmap-wt-rhythm-heatmap)
2D binned heatmap of dwell time vs flight time from `timingAuthenticity.metrics.timingOverTime`. Reveals typing style clusters — touch typists show a tight cluster; hunt-and-peck typists show scattered points. Recommended size: `350px × 350px`.
## Pause Distribution ``
[Section titled “Pause Distribution \”](#pause-distribution-wt-pause-distribution)
Histogram of pause durations from `temporalPatterns.metrics.pauseDistribution`. Uses pre-binned data from the analysis pipeline. Recommended size: `width: 100%; height: 200px`.
# API Reference
> Complete WriteTrack SDK API documentation
Complete reference for all public exports from the WriteTrack SDK.
## Import Paths
[Section titled “Import Paths”](#import-paths)
WriteTrack provides multiple entry points via `package.json` exports:
#### Core
[Section titled “Core”](#core)
| Import Path | Format | Description |
| -------------------- | ----------------- | ---------------------------------------------------------- |
| `writetrack` | ESM, CJS, Browser | Core SDK — WriteTrack class and types |
| `writetrack/pipes` | ESM, CJS, Browser | Output sink factories (also re-exported from `writetrack`) |
| `writetrack/browser` | ESM | Browser bundle (same as core, explicit browser entry) |
#### Frameworks
[Section titled “Frameworks”](#frameworks)
| Import Path | Format | Description |
| ------------------ | ------ | -------------------------------- |
| `writetrack/react` | ESM | React hook — `useWriteTrack` |
| `writetrack/vue` | ESM | Vue composable — `useWriteTrack` |
#### Rich-Text Editors
[Section titled “Rich-Text Editors”](#rich-text-editors)
| Import Path | Format | Description |
| ------------------------ | ------ | ----------------------------------------------- |
| `writetrack/tiptap` | ESM | TipTap extension — `WriteTrackExtension` |
| `writetrack/ckeditor` | ESM | CKEditor 5 plugin — `WriteTrackPlugin` |
| `writetrack/prosemirror` | ESM | ProseMirror plugin — `WriteTrackPlugin` |
| `writetrack/quill` | ESM | Quill module — `WriteTrackModule` |
| `writetrack/lexical` | ESM | Lexical integration — `createWriteTrackLexical` |
| `writetrack/slate` | ESM | Slate integration — `createWriteTrackSlate` |
| `writetrack/tinymce` | ESM | TinyMCE integration — `createWriteTrackTinyMCE` |
#### Utilities
[Section titled “Utilities”](#utilities)
| Import Path | Format | Description |
| ------------------- | ------ | ---------------------------------- |
| `writetrack/verify` | ESM | Server-side signature verification |
| `writetrack/viz` | ESM | Session visualization components |
### ESM (recommended)
[Section titled “ESM (recommended)”](#esm-recommended)
```typescript
import {
WriteTrack,
webhook,
datadog,
segment,
opentelemetry,
analyzeEvents,
formatIndicator,
} from 'writetrack';
import { useWriteTrack } from 'writetrack/react';
import { useWriteTrack } from 'writetrack/vue';
import { WriteTrackExtension } from 'writetrack/tiptap';
import { WriteTrackPlugin } from 'writetrack/ckeditor';
import { WriteTrackPlugin } from 'writetrack/prosemirror';
import { WriteTrackModule } from 'writetrack/quill';
import { createWriteTrackLexical } from 'writetrack/lexical';
import { createWriteTrackSlate } from 'writetrack/slate';
import { createWriteTrackTinyMCE } from 'writetrack/tinymce';
import { verifyAnalysisSignatureAsync } from 'writetrack/verify';
import { WtScorecard } from 'writetrack/viz';
```
### CommonJS
[Section titled “CommonJS”](#commonjs)
```javascript
const { WriteTrack, webhook } = require('writetrack');
```
### Script Tag
[Section titled “Script Tag”](#script-tag)
```html
```
### Types
[Section titled “Types”](#types)
All types are exported from their respective entry points:
```typescript
import type {
WriteTrackOptions,
WriteTrackDataSchema,
SessionAnalysis,
SessionReport,
IndicatorOutput,
KeystrokeEvent,
ClipboardEvent,
SelectionEvent,
UndoRedoEvent,
ProgrammaticInsertionEvent,
CompositionEvent,
InputSource,
WriteTrackSink,
WebhookOptions,
DatadogOptions,
SegmentOptions,
OpenTelemetryOptions,
} from 'writetrack';
```
## WriteTrack Class
[Section titled “WriteTrack Class”](#writetrack-class)
The main class for keystroke tracking and behavioral analysis.
### Constructor
[Section titled “Constructor”](#constructor)
```typescript
new WriteTrack(optionsOrTarget: WriteTrackOptions | HTMLElement)
```
Creates a new WriteTrack instance. Accepts either a full options object or an HTMLElement directly for localhost evaluation.
| Parameter | Type | Description |
| ----------------- | ---------------------------------- | --------------------------------------- |
| `optionsOrTarget` | `WriteTrackOptions \| HTMLElement` | Configuration options or target element |
```typescript
// Full options with context
const tracker = new WriteTrack({
target: document.querySelector('#response-field'),
license: 'wt_live_...',
userId: 'u_abc123',
contentId: 'post_draft_42',
metadata: { formName: 'signup' },
});
// Shorthand (localhost evaluation)
const tracker = new WriteTrack(document.querySelector('#response-field'));
```
### start()
[Section titled “start()”](#start)
```typescript
start(): void
```
Begins recording keystroke events. Clears any previously captured data.
* Resets all event arrays (keystrokes, clipboard, selection, undo/redo, programmatic insertion, composition)
* Records session start time
* Enables event capture
```typescript
const tracker = new WriteTrack({ target: textarea });
tracker.start(); // Now recording
// User types...
const events = tracker.getRawEvents();
```
Note
Calling `start()` resets all captured data, including keystrokes, clipboard events, and selections. The session timer also resets to zero.
### stop()
[Section titled “stop()”](#stop)
```typescript
stop(): void
```
Stops recording keystrokes.
* Disables event capture
* Cleans up selection polling interval and mutation observer
* Data remains available after stopping
```typescript
tracker.stop();
// Data still accessible
const events = tracker.getRawEvents();
```
Tip
After calling `stop()`, all captured data remains accessible. You can retrieve events and features even after stopping the tracker.
### getData()
[Section titled “getData()”](#getdata)
```typescript
getData(): WriteTrackDataSchema
```
Returns the complete session as a structured [`WriteTrackDataSchema`](#writetrackdataschema) object, including metadata, all captured events, and computed quality metrics.
```typescript
tracker.stop();
const data = tracker.getData();
// {
// version: "2.1.0",
// metadata: { tool, targetElement, timestamp, duration },
// session: { events, clipboardEvents, selectionEvents, compositionEvents, ... },
// quality: { overallScore, sequenceValid, qualityLevel, ... },
// }
// Send to your server
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
});
```
### getText()
[Section titled “getText()”](#gettext)
```typescript
getText(): string
```
Returns the current text content of the target element. Reads `value` for input/textarea elements, or `textContent` for other elements.
### getRawEvents()
[Section titled “getRawEvents()”](#getrawevents)
```typescript
getRawEvents(): KeystrokeEvent[]
```
Returns a copy of all captured keystroke events.
```typescript
const events = tracker.getRawEvents();
console.log(`Captured ${events.length} keystrokes`);
events
.filter((e) => e.type === 'keydown')
.forEach((e) => {
console.log(`${e.key} at ${e.timestamp}ms, flight: ${e.flightTime}ms`);
});
```
### getClipboardEvents()
[Section titled “getClipboardEvents()”](#getclipboardevents)
```typescript
getClipboardEvents(): ClipboardEvent[]
```
Returns a copy of all clipboard events (copy, paste, cut).
```typescript
const pastes = tracker.getClipboardEvents().filter((e) => e.type === 'paste');
console.log(`User pasted ${pastes.length} times`);
pastes.forEach((e) => {
console.log(`Pasted ${e.length} chars at position ${e.position}`);
});
```
### getSelectionEvents()
[Section titled “getSelectionEvents()”](#getselectionevents)
```typescript
getSelectionEvents(): SelectionEvent[]
```
Returns text selection events.
```typescript
const selections = tracker.getSelectionEvents();
selections.forEach((e) => {
console.log(`Selected ${e.selectedLength} chars via ${e.method}`);
});
```
### getUndoRedoEvents()
[Section titled “getUndoRedoEvents()”](#getundoredoevents)
```typescript
getUndoRedoEvents(): UndoRedoEvent[]
```
Returns undo/redo operation events.
### getProgrammaticInsertionEvents()
[Section titled “getProgrammaticInsertionEvents()”](#getprogrammaticinsertionevents)
```typescript
getProgrammaticInsertionEvents(): ProgrammaticInsertionEvent[]
```
Returns detected programmatic insertion events. These are emitted when text appears in the input without corresponding keystrokes, indicating browser autocomplete, autofill, predictive text, AI extension injection, voice dictation, or programmatic value assignment.
```typescript
const insertions = tracker.getProgrammaticInsertionEvents();
insertions.forEach((e) => {
console.log(
`Programmatic insertion: "${e.insertedText}" (${e.insertedLength} chars)`
);
});
```
### getCompositionEvents()
[Section titled “getCompositionEvents()”](#getcompositionevents)
```typescript
getCompositionEvents(): CompositionEvent[]
```
Returns IME composition events captured during the session. A composition event is emitted when a CJK (Chinese, Japanese, Korean) input method completes a composition sequence. Intermediate keystrokes during composition are suppressed.
```typescript
const compositions = tracker.getCompositionEvents();
compositions.forEach((e) => {
console.log(`Composition: ${e.insertedLength} chars in ${e.duration}ms`);
});
```
### getSessionDuration()
[Section titled “getSessionDuration()”](#getsessionduration)
```typescript
getSessionDuration(): number
```
Returns the total session duration in milliseconds since `start()` was called, including time when the tab was hidden.
```typescript
const duration = tracker.getSessionDuration();
console.log(`Session: ${(duration / 1000).toFixed(1)} seconds`);
```
### getActiveTime()
[Section titled “getActiveTime()”](#getactivetime)
```typescript
getActiveTime(): number
```
Returns the active (visible) session time in milliseconds. This subtracts time spent with the tab hidden from the total session duration. Returns `0` before `start()` is called.
```typescript
const active = tracker.getActiveTime();
const total = tracker.getSessionDuration();
console.log(
`Active: ${(active / 1000).toFixed(1)}s of ${(total / 1000).toFixed(1)}s total`
);
```
### getKeystrokeCount()
[Section titled “getKeystrokeCount()”](#getkeystrokecount)
```typescript
getKeystrokeCount(): number
```
Returns the total number of keydown events captured.
### pipe()
[Section titled “pipe()”](#pipe)
```typescript
pipe(sink: WriteTrackSink): this
```
Registers an output sink to receive session data when `getData()` is called. Returns `this` for chaining.
```typescript
import { WriteTrack, webhook } from 'writetrack';
tracker
.pipe(webhook({ url: 'https://api.example.com/typing' }))
.pipe({ send: async (data) => console.log(data) });
```
See [Output Sinks](/docs/output-sinks/) for full documentation of the pipe system and available sinks.
### on()
[Section titled “on()”](#on)
```typescript
on(event: string, handler: (...args: unknown[]) => void): this
```
Registers an event listener.
**Supported events:**
| Event | Handler signature | Description |
| ------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------- |
| `pipe:error` | `(err: Error, sink: WriteTrackSink) => void` | Fires when a sink fails |
| `tick` | `(data: { activeTime: number; totalTime: number }) => void` | Fires every \~1 second while the session is active and the tab is visible |
```typescript
tracker.on('pipe:error', (err, sink) => {
console.error('Sink failed:', err);
});
// Display a live session timer
tracker.on('tick', ({ activeTime, totalTime }) => {
const seconds = Math.floor(activeTime / 1000);
console.log(`Active: ${seconds}s`);
});
```
Note
The `tick` interval starts automatically when a `tick` listener is registered (via `on('tick', ...)`) and the session is active. If listeners are registered before `start()`, the interval begins on `start()`. The interval is cleared on `stop()`.
### isLicenseValid()
[Section titled “isLicenseValid()”](#islicensevalid)
```typescript
isLicenseValid(): boolean
```
Returns whether a valid, non-expired license key has been verified for the current domain. Returns `false` when no license key is provided, during the grace period, or when validation fails. Recording (event capture) works regardless of license status — only `getAnalysis()` requires a valid license, grace period, or localhost.
### isLicenseValidated()
[Section titled “isLicenseValidated()”](#islicensevalidated)
```typescript
isLicenseValidated(): boolean
```
Returns whether license validation has been performed.
```typescript
if (tracker.isLicenseValidated() && tracker.isLicenseValid()) {
console.log('License valid, ready to use');
}
```
### isTargetDetached()
[Section titled “isTargetDetached()”](#istargetdetached)
```typescript
isTargetDetached(): boolean
```
Returns whether the monitored target element has been removed from the DOM. Useful for detecting SPA navigation that destroys the tracked element.
```typescript
if (tracker.isTargetDetached()) {
console.warn('Target element removed — call stop() and create a new tracker');
}
```
### ready
[Section titled “ready”](#ready)
```typescript
ready: Promise;
```
A promise that resolves when the tracker is ready for use. When `persist: true`, this resolves after IndexedDB loads any prior session data. When `persist: false`, it resolves immediately. After `stop()` with persistence enabled, `ready` resolves when the save completes.
```typescript
const tracker = new WriteTrack({
target: textarea,
contentId: 'doc-1',
persist: true,
});
await tracker.ready; // IndexedDB loaded
tracker.start(); // Will auto-resume prior session if available
```
### clearPersistedData()
[Section titled “clearPersistedData()”](#clearpersisteddata)
```typescript
async clearPersistedData(): Promise
```
Removes any persisted session data from IndexedDB for this field. No-op when `persist` is not enabled. Use this to reset a field’s session history.
```typescript
await tracker.clearPersistedData();
```
### getAnalysis()
[Section titled “getAnalysis()”](#getanalysis)
```typescript
async getAnalysis(): Promise
```
Runs WASM-powered analysis on the captured session data. The WASM binary is lazy-loaded on the first call and cached for subsequent calls. Returns `null` if the WASM module fails to load.
```typescript
const tracker = new WriteTrack({
target: textarea,
wasmUrl: '/writetrack.wasm',
});
tracker.start();
// ... user types ...
tracker.stop();
const analysis = await tracker.getAnalysis();
// {
// version: "0.9.1",
// sufficientData: true,
// contentOrigin: { ... },
// timingAuthenticity: { ... },
// ...
// }
```
Note
All WASM imports are dynamic — if `getAnalysis()` is never called, zero analysis code is loaded. Capture-only consumers pay no bundle cost.
### getSessionReport()
[Section titled “getSessionReport()”](#getsessionreport)
```typescript
async getSessionReport(): Promise
```
Returns both the raw capture data and the WASM analysis in a single call.
```typescript
const report = await tracker.getSessionReport();
// {
// data: WriteTrackDataSchema, // Same as getData()
// analysis: SessionAnalysis, // Same as getAnalysis()
// }
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(report),
});
```
### wasmReady
[Section titled “wasmReady”](#wasmready)
```typescript
get wasmReady(): boolean
```
Whether the WASM module has been loaded and is ready for analysis. Returns `false` until `getAnalysis()` is called for the first time and the WASM binary loads successfully.
```typescript
const tracker = new WriteTrack({ target: textarea });
console.log(tracker.wasmReady); // false
await tracker.getAnalysis();
console.log(tracker.wasmReady); // true (if WASM loaded)
```
### Static: listPersistedSessions()
[Section titled “Static: listPersistedSessions()”](#static-listpersistedsessions)
```typescript
static async listPersistedSessions(): Promise
```
Returns metadata for all persisted sessions stored in IndexedDB. Useful for multi-document apps that need to display or manage saved writing sessions.
```typescript
const sessions = await WriteTrack.listPersistedSessions();
// [{ contentId: 'doc_1', fieldId: 'default', keystrokeCount: 42, lastSavedAt: 1709..., sessionId: '...' }]
```
### Static: deletePersistedSession()
[Section titled “Static: deletePersistedSession()”](#static-deletepersistedsession)
```typescript
static async deletePersistedSession(contentId: string, fieldId?: string): Promise
```
Deletes persisted session data from IndexedDB. If `fieldId` is omitted, deletes all sessions for the given `contentId`.
```typescript
// Delete all sessions for a document
await WriteTrack.deletePersistedSession('doc_1');
// Delete a specific field's session
await WriteTrack.deletePersistedSession('doc_1', 'response');
```
## Pipes Exports
[Section titled “Pipes Exports”](#pipes-exports)
```typescript
import { webhook, datadog, segment, opentelemetry } from 'writetrack';
```
| Export | Returns | Description |
| ------------------------ | ---------------- | --------------------------------- |
| `webhook(options)` | `WriteTrackSink` | POST session data to a URL |
| `datadog(options)` | `WriteTrackSink` | Send as Datadog RUM custom action |
| `segment(options)` | `WriteTrackSink` | Send as Segment track event |
| `opentelemetry(options)` | `WriteTrackSink` | Send as OpenTelemetry span |
See [Output Sinks](/docs/output-sinks/) for configuration options and usage guides.
## Standalone Exports
[Section titled “Standalone Exports”](#standalone-exports)
### analyzeEvents()
[Section titled “analyzeEvents()”](#analyzeevents)
```typescript
import { analyzeEvents } from 'writetrack';
async function analyzeEvents(
rawEventData: unknown,
options?: { wasmUrl?: string; licenseKey?: string }
): Promise;
```
Analyze a `WriteTrackDataSchema` object without needing a live DOM element. Useful for server-side analysis of previously captured session data.
```typescript
// Server-side: analyze data received from the client
const data = await request.json(); // WriteTrackDataSchema from getData()
const analysis = await analyzeEvents(data, {
licenseKey: process.env.WRITETRACK_LICENSE_KEY,
});
```
### formatIndicator()
[Section titled “formatIndicator()”](#formatindicator)
```typescript
import { formatIndicator } from 'writetrack';
function formatIndicator(indicator: IndicatorOutput): string;
```
Converts a machine-readable WT-NNN indicator code (from `SessionAnalysis`) into a human-readable English string.
```typescript
const analysis = await tracker.getAnalysis();
if (analysis) {
const msg = formatIndicator(analysis.contentOrigin.indicator);
// e.g., "85% of text was pasted from external sources"
}
```
### createSessionReport()
[Section titled “createSessionReport()”](#createsessionreport)
```typescript
import { createSessionReport } from 'writetrack';
async function createSessionReport(
rawData: unknown,
options?: { wasmUrl?: string; licenseKey?: string }
): Promise;
```
Analyze raw session data and return a complete `SessionReport` (data + analysis). Wraps `analyzeEvents()` — accepts `WriteTrackDataSchema` (from `getData()`) or raw typing-sample format (with `rawEvents` instead of `session`). The returned report is ready for `scorecard.setData()`.
```typescript
const data = await request.json();
const report = await createSessionReport(data, {
licenseKey: process.env.WRITETRACK_LICENSE_KEY,
});
// report.data — normalized input data
// report.analysis — SessionAnalysis result
```
## Types
[Section titled “Types”](#types-1)
### WriteTrackOptions
[Section titled “WriteTrackOptions”](#writetrackoptions)
Configuration options for the WriteTrack constructor.
```typescript
interface WriteTrackOptions {
target: HTMLElement; // Required: Input element to monitor
license?: string; // Optional: License key for production
userId?: string; // Optional: User identifier (included in metadata)
contentId?: string; // Optional: Content/document identifier (included in metadata)
metadata?: Record; // Optional: Arbitrary tags (appears as metadata.custom)
wasmUrl?: string; // Optional: URL to WASM binary for analysis
persist?: boolean; // Optional: Enable IndexedDB session persistence (requires contentId)
cursorPositionProvider?: () => number; // Optional: Custom cursor position for rich-text editors
inputSourceProvider?: () => InputSource | undefined; // Optional: Callback returning current input source classification. Set automatically by editor integrations.
}
```
### PersistedSessionInfo
[Section titled “PersistedSessionInfo”](#persistedsessioninfo)
Metadata about a persisted writing session, returned by `listPersistedSessions()`.
```typescript
interface PersistedSessionInfo {
contentId: string; // Document/content identifier
fieldId: string; // Field identifier within the document
keystrokeCount: number; // Number of keystrokes in the persisted session
lastSavedAt: number; // Unix timestamp of last save
sessionId: string; // Unique session ID
}
```
### WriteTrackDataSchema
[Section titled “WriteTrackDataSchema”](#writetrackdataschema)
The complete session output returned by `getData()`.
```typescript
interface WriteTrackDataSchema {
version: '2.1.0';
metadata: SessionMetadata;
session: RawEventData;
quality: DataQualityMetrics;
unlicensed?: boolean;
}
```
### SessionMetadata
[Section titled “SessionMetadata”](#sessionmetadata)
Session metadata included in the export.
```typescript
interface SessionMetadata {
tool: { name: string; version: string };
targetElement: string; // e.g., 'textarea', 'input'
fieldId: string; // Derived from data-writetrack-field, id, or name attribute
sessionId: string; // Unique session ID (stable across resume)
segment?: number; // Resume segment counter (present when session was resumed)
timestamp: string; // ISO 8601
duration: number; // ms
userId?: string; // From WriteTrackOptions.userId
contentId?: string; // From WriteTrackOptions.contentId
custom?: Record; // From WriteTrackOptions.metadata
}
```
### DataQualityMetrics
[Section titled “DataQualityMetrics”](#dataqualitymetrics)
Quality assessment of the captured session.
```typescript
interface DataQualityMetrics {
overallScore: number; // 0-1
completeness: number; // 0-1
sequenceValid: boolean; // Timestamps monotonically increasing
timingValid: boolean; // Session has valid timing data
sessionDuration: number; // ms
eventCount: number;
qualityLevel: 'POOR' | 'FAIR' | 'GOOD' | 'EXCELLENT';
issues: string[]; // Human-readable quality issues
validatedAt: string; // ISO 8601
}
```
### KeystrokeEvent
[Section titled “KeystrokeEvent”](#keystrokeevent)
Individual keystroke event data.
```typescript
interface KeystrokeEvent {
key: string; // Character or key name
code: string; // Physical key code
type: 'keydown' | 'keyup';
timestamp: number; // High-precision timestamp
dwellTime?: number; // Key hold duration (keyup only)
flightTime?: number; // Time since last key (keydown only)
isCorrection?: boolean; // Backspace/Delete/ArrowLeft/ArrowRight
windowFocused: boolean;
timeSinceLastMouse: number;
isTrusted?: boolean; // true if user-generated, false if scripted
inputSource?: InputSource; // Input source classification, set by editor integrations or beforeinput fallback
isBurst?: boolean; // Schema field, populated by analysis pipelines
cursorPosition?: number; // Schema field, populated by analysis pipelines
modifiers?: ModifierState; // Schema field, populated by analysis pipelines
}
```
Note
`isBurst`, `cursorPosition`, and `modifiers` are defined in the schema type but not populated by the core `WriteTrack` class during capture. They may be set by external analysis pipelines or WASM processing.
### ClipboardEvent
[Section titled “ClipboardEvent”](#clipboardevent)
Clipboard operation event data.
```typescript
interface ClipboardEvent {
type: 'copy' | 'paste' | 'cut';
timestamp: number;
position: number;
length: number;
shortcut: string; // e.g., 'Ctrl+V', 'Cmd+V'
content?: string; // Clipboard content (paste only)
beforeText?: string; // Text state before operation
afterText?: string; // Text state after operation
replacedText?: string; // Text that was replaced
replacedRange?: {
start: number;
end: number;
};
isTrusted?: boolean; // true if user-generated, false if scripted
inputSource?: InputSource; // Input source classification, set by editor integrations or beforeinput fallback
}
```
### SelectionEvent
[Section titled “SelectionEvent”](#selectionevent)
Text selection event data.
```typescript
interface SelectionEvent {
type: 'select';
timestamp: number;
startPosition: number;
endPosition: number;
selectedLength: number;
selectedText?: string; // The selected text content (omitted when privacy-sensitive)
method: 'mouse' | 'keyboard' | 'programmatic';
isTrusted?: boolean; // true if user-generated, false if scripted
}
```
### ProgrammaticInsertionEvent
[Section titled “ProgrammaticInsertionEvent”](#programmaticinsertionevent)
Programmatic insertion event data.
```typescript
interface ProgrammaticInsertionEvent {
type: 'programmatic-insertion';
timestamp: number;
insertedText: string; // Text that was inserted
insertedLength: number; // Character count
position: number; // Cursor position where text was inserted
isTrusted: boolean; // Whether the triggering input event was user-generated
inputType?: string; // InputEvent.inputType if available
inputSource?: InputSource; // Input source classification, set by editor integrations or beforeinput fallback
}
```
### UndoRedoEvent
[Section titled “UndoRedoEvent”](#undoredoevent)
Undo/redo operation event data.
```typescript
interface UndoRedoEvent {
type: 'undo' | 'redo';
timestamp: number;
shortcut: string; // e.g., 'Ctrl+Z'
beforeText: string;
afterText: string;
}
```
### CompositionEvent
[Section titled “CompositionEvent”](#compositionevent)
IME composition event data. Emitted when a CJK input method or dead-key sequence completes.
```typescript
interface CompositionEvent {
timestamp: number; // When the composition started
duration: number; // Duration of the composition sequence (ms)
insertedLength: number; // Characters inserted (0 if composition was abandoned)
isTrusted: boolean; // Whether the compositionend event was user-generated
}
```
### SessionAnalysis
[Section titled “SessionAnalysis”](#sessionanalysis)
The WASM analysis output returned by `getAnalysis()`. Contains indicator codes and rich metrics for each analysis dimension.
```typescript
interface SessionAnalysis {
version: string;
locale: string;
analyzedAt: number;
sufficientData: boolean;
keydownCount: number;
integrity: IntegrityProof;
sessionTimeline: SessionSegment[];
initialTextLength: number;
initialTextHash: string;
finalTextLength: number;
finalTextHash: string;
contentOrigin: ContentOriginAnalysis;
timingAuthenticity: TimingAuthenticityAnalysis;
sessionContinuity: SessionContinuityAnalysis;
physicalPlausibility: PhysicalPlausibilityAnalysis;
revisionBehavior: RevisionBehaviorAnalysis;
temporalPatterns: TemporalPatternsAnalysis;
writingProcess: WritingProcessAnalysis;
outputSignature: string;
signedPayload?: string;
}
```
Each analysis dimension (e.g., `contentOrigin`, `timingAuthenticity`) contains an `indicator` with a machine-readable `code` (WT-100 through WT-606) and numeric `params`, plus a `metrics` object with detailed data and time series suitable for visualization. Content origin additionally has a `pasteReworkIndicator` (WT-105 through WT-107) for paste editing behavior.
The `outputSignature` field contains a cryptographic signature over the analysis output, verifiable via `verifyAnalysisSignatureAsync()` from `writetrack/verify`. The optional `signedPayload` field contains the exact JSON bytes that were signed, for cross-language verification.
### WritingProcessAnalysis
[Section titled “WritingProcessAnalysis”](#writingprocessanalysis)
Writing process stage classification returned in `SessionAnalysis.writingProcess`.
```typescript
interface WritingProcessAnalysis {
indicator: IndicatorOutput;
metrics: {
segments: WritingProcessSegment[];
transitions: PhaseTransition[];
timeInPhase: {
planning: number; // Fraction of session time (0-1)
drafting: number;
revision: number;
};
phaseChangeCount: number;
windowDurationMs: number;
phaseTimeline: TimeSeries;
};
}
interface WritingProcessSegment {
startTime: number; // ms from session start
endTime: number;
phase: 'planning' | 'drafting' | 'revision';
confidence: number; // 0-1
features: WindowFeatures;
}
interface PhaseTransition {
from: 'planning' | 'drafting' | 'revision';
to: 'planning' | 'drafting' | 'revision';
count: number;
probability: number; // 0-1
}
```
### SessionReport
[Section titled “SessionReport”](#sessionreport)
Combined output of `getSessionReport()`.
```typescript
interface SessionReport {
data: WriteTrackDataSchema;
analysis: SessionAnalysis | null;
}
```
### IndicatorOutput
[Section titled “IndicatorOutput”](#indicatoroutput)
Machine-readable indicator returned by each analysis dimension.
```typescript
interface IndicatorOutput {
code: string; // WT-NNN code (e.g., "WT-101")
params: Record; // Numeric parameters (e.g., { pct: 85 })
}
```
Use `formatIndicator()` to convert to a human-readable English string.
# Changelog
> WriteTrack SDK release notes and version history
## v0.10.1 Current
[Section titled “v0.10.1 ”](#v0101)
Scorecard empty-state polish and writetracker UX improvements.
### Bug Fixes
[Section titled “Bug Fixes”](#bug-fixes)
* Scorecard cells and detail panel no longer show “flagged” status when session has insufficient data
* Empty compact charts no longer display placeholder text
* Fixed scorecard flag section appearing on empty sessions
***
## v0.10.0
[Section titled “v0.10.0”](#v0100)
Redesigned scorecard with new chart components, share/download actions, and improved empty-state handling.
### Redesigned Scorecard New
[Section titled “Redesigned Scorecard ”](#redesigned-scorecard)
* New 6-cell grid with compact chart previews for each analysis category
* Content origin bar showing typed vs pasted proportion at a glance
* Share and download action buttons with PNG export
* **``** — per-keystroke edit visualization
* **``** — headline metric badges for key session stats
### Improvements
[Section titled “Improvements”](#improvements)
* More responsive chart rendering
* Improved license validation error messages
* Reduced false positives for paste-heavy sessions
### Bug Fixes
[Section titled “Bug Fixes”](#bug-fixes-1)
* Fixed paste source labeling in scorecard
* Fixed indicator formatting and priority ordering
***
## v0.9.1
[Section titled “v0.9.1”](#v091)
Detection improvements and bug fixes.
### Detection
[Section titled “Detection”](#detection)
* Enhanced modifier key tracking for automation detection
* Three new indicators targeting uniform timing, headless execution, and synthetic input patterns
* Reduced false positives for autocomplete and paste-heavy sessions
* Richer burst analysis diagnostics
### Bug Fixes
[Section titled “Bug Fixes”](#bug-fixes-2)
* Fixed paste event capture in nested contenteditable elements
* Fixed stale timestamps on persisted session restore
* Fixed `analysis.version` reporting hardcoded `1.0.0` instead of SDK version
* Fixed persistence race condition on async session restore
* Fixed chart rendering artifacts on speed timeline
***
## v0.9.0
[Section titled “v0.9.0”](#v090)
Better editor integration, active session tracking, and improved paste-edit heuristics.
### Editor Input Source Classification New
[Section titled “Editor Input Source Classification ”](#editor-input-source-classification)
All editor integrations now classify the source of each input event — `keyboard`, `paste`, `drop`, `cut`, `autocomplete`, `spellcheck`, `composition`, `undo`, `redo`, or `api`. Uses editor transaction metadata with W3C `beforeinput` fallback for plain elements. New `inputSourceProvider` option for custom integrations.
### Active Session Duration New
[Section titled “Active Session Duration ”](#active-session-duration)
Track how long a user has been actively writing without rolling your own timer. `on('tick', handler)` fires every second while the session is active and the tab is visible, providing `activeTime` and `totalTime` in milliseconds. `getActiveTime()` returns active time on demand.
### Paste-Edit Analysis
[Section titled “Paste-Edit Analysis”](#paste-edit-analysis)
* Per-paste `reworkRatio` metric tracking edits within paste regions over a 60-second window
* 6 aggregate paste metrics in `PasteEditSummary`
* New WT-105/106/107 paste rework indicators
***
## v0.8.0
[Section titled “v0.8.0”](#v080)
### Writing Process Stage Detection New
[Section titled “Writing Process Stage Detection ”](#writing-process-stage-detection)
New analysis category that classifies temporal windows as planning, drafting, or revision using existing keystroke metrics. Outputs phase transition matrix for flow visualization, aggregate time-in-phase percentages, and per-segment phase labels with confidence scores.
### Editor Integrations New
[Section titled “Editor Integrations ”](#editor-integrations)
Three new first-party editor integrations:
* **Lexical** (`writetrack/lexical`) — cursor tracking via `$getSelection()`
* **Slate** (`writetrack/slate`) — element-to-path cursor tracking
* **TinyMCE** (`writetrack/tinymce`) — inline mode with init/destroy lifecycle
### Analysis & Bug Fixes
[Section titled “Analysis & Bug Fixes”](#analysis--bug-fixes)
* Paste-edit correlation metrics — track edits within paste regions
* Fixed false Physical Plausibility flags caused by dwell time encoding bugs
* Improved burst ratio and timing threshold accuracy
* Zero-latency events now flagged as physically implausible
* Documentation hardening and stale reference cleanup
***
## v0.7.0
[Section titled “v0.7.0”](#v070)
This release adds a visualization module with 9 chart components, a redesigned session scorecard, and improvements across the analysis pipeline.
### Visualization Module New
[Section titled “Visualization Module ”](#visualization-module)
New `writetrack/viz` subpath export with chart components rendered as web components powered by Observable Plot.
* **Analysis charts** — SpeedTimeline, CompositionTimeline, RhythmHeatmap, PauseDistribution
* **Event-detail charts** — Sparkline, DocumentGrowth, DocumentRibbon, EditWaterfall, CorrectionsBubble
* **Session scorecard** — Experimental `` implementation with indicator grid, detail panels, and integrity verification
* **Theming** — CSS custom properties with warm editorial palette (light/dark), overridable fonts via `--wt-scorecard-font-*`
* **Extract functions** — Standalone data-extraction functions for each chart, usable without web components
* **Hover tooltips** — Interactive tooltips on all chart components
* **LTTB downsampling** — Time-series charts automatically downsample large datasets via WASM for responsive rendering
### Analysis
[Section titled “Analysis”](#analysis)
* Improved analysis module with new indicators and metrics for more accurate detection of automated and authentic writing patterns
### Bug Fixes
[Section titled “Bug Fixes”](#bug-fixes-3)
* Fixed paste content capture in ProseMirror/TipTap editors
* Fixed contenteditable cursor position tracking
* Fixed WASM loading reliability across deployment environments
***
## v0.6.1
[Section titled “v0.6.1”](#v061)
* **Improved bundler compatibility** — WASM analysis module works reliably across webpack 5, Rollup, Next.js, and Vite
* **Bug fixes** — Improvements to the customer portal
***
## v0.6.0
[Section titled “v0.6.0”](#v060)
This release improves analysis accuracy and adds convenience methods for managing persisted sessions.
### Analysis Improvements
[Section titled “Analysis Improvements”](#analysis-improvements)
* **New analysis features** — Enhanced timing, behavioral, and revision pattern detection for more accurate indicators
* **Consistent indicator codes** — Indicators now use a category-prefixed scheme (WT-1xx through WT-6xx)
### Persistence Management New
[Section titled “Persistence Management ”](#persistence-management)
* **[`listPersistedSessions()`](/docs/api-reference/#static-listpersistedsessions)** — List all stored sessions without creating a tracker instance
* **[`deletePersistedSession()`](/docs/api-reference/#static-deletepersistedsession)** — Delete sessions by `contentId`, useful for cleanup when users delete content
### Editor Integrations
[Section titled “Editor Integrations”](#editor-integrations)
* All integrations (TipTap, CKEditor, ProseMirror, Quill, React hook, Vue hook) now support `persist` and `wasmUrl` option passthrough
### Verification API New
[Section titled “Verification API ”](#verification-api)
New [`writetrack/verify`](/docs/api-reference/#writtrackverify) subpath export for standalone session verification without a DOM element.
***
## v0.5.0
[Section titled “v0.5.0”](#v050)
The latest version of WriteTrack introduces typing pattern analysis, session persistence, and expanded input capture.
### Analysis Module New
[Section titled “Analysis Module ”](#analysis-module)
WriteTrack now includes an integrated analysis engine. Each indicator returns a standardized diagnostic code and human-readable explanation. Results include a signed integrity proof.
* **Content origin** — typed vs. pasted vs. autocompleted ratio
* **Timing authenticity** — keystroke rhythm and variability
* **Session continuity** — tab-away events and behavioral shifts
* **Physical plausibility** — input consistency checks
* **Revision behavior** — correction rate and editing patterns
* **Temporal patterns** — speed variation, fatigue, and burst detection
### Session Persistence New
[Section titled “Session Persistence ”](#session-persistence)
Sessions can now survive page reloads. Pass `persist: true` with a `contentId` to automatically save session state to IndexedDB and restore it on the next page load. New `clearPersistedData()` method for cleanup.
### Capture Improvements
[Section titled “Capture Improvements”](#capture-improvements)
* **IME composition support** — [CJK](https://en.wikipedia.org/wiki/CJK_characters) input methods, dead keys, and accent input now tracked correctly
* **Tab blur detection** — keystrokes arriving while the tab is backgrounded are identified
* **Paste source classification** — pastes categorized by source
* **Programmatic insertion** — now captures the browser’s `inputType` for finer-grained classification
### Bug Fixes
[Section titled “Bug Fixes”](#bug-fixes-4)
* Fixed memory leak where event listeners weren’t removed on `stop()`
* Improved accuracy of behavioral analysis
* Various fixes to contenteditable support and React hook
## v0.4.3
[Section titled “v0.4.3”](#v043)
### Editor Integrations New
[Section titled “Editor Integrations ”](#editor-integrations--1)
We’ve added support for the most popular rich-text editors via dedicated imports. Each editor library is an optional peer dependency, only included in your bundle if you use it.
* **[TipTap](/docs/integrations/tiptap/)** – headless and extensible (`writetrack/tiptap`)
* **[CKEditor 5](/docs/integrations/ckeditor/)** – feature-rich, used by enterprise teams (`writetrack/ckeditor`)
* **[ProseMirror](/docs/integrations/prosemirror/)** – low-level toolkit for custom editors (`writetrack/prosemirror`)
* **[Quill](/docs/integrations/quill/)** – lightweight and easy to get started (`writetrack/quill`)
### Documentation
[Section titled “Documentation”](#documentation)
* Consolidated API reference into a single [page](/docs/api-reference/)
* Improved styling and clarity across all docs pages
## v0.3.1
[Section titled “v0.3.1”](#v031)
* Documentation now accurately reflects the public npm package API
## v0.3.0
[Section titled “v0.3.0”](#v030)
This release introduces the output sink system for routing session data to external services, alongside broader browser support and improved event capture.
### Output Sinks New
[Section titled “Output Sinks ”](#output-sinks)
New [`.pipe()` API](/docs/output-sinks/) for routing session data to one or more destinations. Ships with built-in sinks for [Webhook](/docs/sink-webhook/), [Datadog](/docs/sink-datadog/), [Segment](/docs/sink-segment/), and [OpenTelemetry](/docs/sink-opentelemetry/), plus support for [custom sinks](/docs/sink-custom/).
### Event Capture New
[Section titled “Event Capture ”](#event-capture)
* **`isTrusted` flag** — Keystroke events now include the browser’s `isTrusted` property to distinguish real user input from synthetic events
* **Programmatic insertion detection** — Browser autofill, predictive text, and other non-keystroke insertions are captured as dedicated events
* **Context fields** — New `userId`, `contentId`, and `metadata` constructor options that flow through to `getData()` output and all sinks
### Browser Support
[Section titled “Browser Support”](#browser-support)
* Broader browser support for license validation — now works in **Chrome 37+**, Firefox 34+, Safari 11+, Edge 12+
## v0.1.0
[Section titled “v0.1.0”](#v010)
Initial release of the SDK and [writetrack.dev](https://writetrack.dev).
### SDK New
[Section titled “SDK ”](#sdk)
* **Keystroke Capture** — Keydown/keyup events with high-resolution timestamps, dwell time, flight time, cursor position and selection tracking
* **Paste & Edit Tracking** — Clipboard event capture with before/after text states, selection events via mouse/keyboard/programmatic methods, edit sequence pattern detection, revision depth tracking
* **Privacy-First Architecture** — Client-side only processing, no data transmitted externally, offline license validation, no PII collection or device fingerprinting
* **Framework Support** — React hook (`useWriteTrack`), Vue 3 composable, Next.js (App Router and Pages Router), vanilla JS
* **Multi-Format Output** — ESM, CommonJS, and browser bundle. Zero dependencies
### Documentation New
[Section titled “Documentation ”](#documentation)
* **Getting Started** — Introduction, quickstart guide, configuration reference
* **Framework Guides** — React, Vue, and Next.js integration walkthroughs
* **Core API** — WriteTrack class reference, feature extraction guide, full TypeScript type definitions
* **Advanced** — Data schema specification, privacy and security guidance
* **Reference** — API quick-reference tables, testing strategies, troubleshooting guide
### Customer Portal New
[Section titled “Customer Portal ”](#customer-portal)
* **Authentication** — Email/password login and signup with email verification
* **License Dashboard** — License tier display, expiry tracking
* **Domain Management** — Add/remove licensed domains with tier-based limits
# Data Schema
> WriteTrack event types and output format
WriteTrack captures multiple types of events during typing sessions. This page documents the complete data schema.
> **Schema Version:** v2.1.0
Note
Schema v2.0.0 added enhanced paste and edit tracking. v2.1.0 added `compositionEvents`. Samples created with earlier versions may lack newer fields.
## Event Types
[Section titled “Event Types”](#event-types)
### Keystroke Events
[Section titled “Keystroke Events”](#keystroke-events)
The primary event type, capturing individual key presses and releases.
```typescript
interface KeystrokeEvent {
key: string; // Character or key name
code: string; // Physical key code
type: 'keydown' | 'keyup';
timestamp: number; // High-precision timestamp (ms)
dwellTime?: number; // Key hold duration (keyup only)
flightTime?: number; // Time since last key (keydown only)
isCorrection?: boolean; // True for Backspace, Delete, ArrowLeft, ArrowRight
windowFocused: boolean; // Window focus state
timeSinceLastMouse: number; // ms since last mouse activity
isTrusted?: boolean; // true if user-generated, false if scripted
inputSource?: InputSource; // Input source classification, set by editor integrations or beforeinput fallback
}
```
#### Dwell and Flight Time
[Section titled “Dwell and Flight Time”](#dwell-and-flight-time)
**Dwell time**: How long a key is held down (keydown to keyup).
* Only present on `keyup` events
* Typical human range: 50-200ms
**Flight time**: Time between releasing the previous key and pressing this one.
* Only present on `keydown` events
* Typical human range: 50-500ms
### Clipboard Events
[Section titled “Clipboard Events”](#clipboard-events)
Captures copy, paste, and cut operations.
```typescript
interface ClipboardEvent {
type: 'copy' | 'paste' | 'cut';
timestamp: number;
position: number; // Cursor position
length: number; // Length of content
shortcut: string; // e.g., 'Ctrl+V', 'Cmd+V'
content?: string; // Actual content (paste only)
beforeText?: string; // Text state before operation
afterText?: string; // Text state after operation
replacedText?: string; // Text that was replaced
replacedRange?: {
start: number;
end: number;
};
isTrusted?: boolean; // true if user-generated, false if scripted
inputSource?: InputSource; // Input source classification, set by editor integrations or beforeinput fallback
}
```
#### Example Paste Event
[Section titled “Example Paste Event”](#example-paste-event)
```typescript
{
type: 'paste',
timestamp: 1234567890.12,
position: 42,
length: 150,
shortcut: 'Cmd+V',
content: 'The pasted text content...',
beforeText: 'Some existing text',
afterText: 'Some existing textThe pasted text content...',
replacedText: '', // Nothing was selected/replaced
replacedRange: undefined
}
```
### Selection Events
[Section titled “Selection Events”](#selection-events)
Captures text selection actions.
```typescript
interface SelectionEvent {
type: 'select';
timestamp: number;
startPosition: number;
endPosition: number;
selectedLength: number;
selectedText?: string; // The selected text content (omitted when privacy-sensitive)
method: 'mouse' | 'keyboard' | 'programmatic';
isTrusted?: boolean; // true if user-generated, false if scripted
}
```
### Undo/Redo Events
[Section titled “Undo/Redo Events”](#undoredo-events)
Captures undo and redo operations.
```typescript
interface UndoRedoEvent {
type: 'undo' | 'redo';
timestamp: number;
shortcut: string; // e.g., 'Ctrl+Z', 'Meta+Z'
beforeText: string;
afterText: string;
}
```
### Programmatic Insertion Events
[Section titled “Programmatic Insertion Events”](#programmatic-insertion-events)
Detects text inserted without corresponding keystrokes, such as browser autocomplete, autofill, predictive text, AI extension injection, voice dictation, or programmatic value assignment.
```typescript
interface ProgrammaticInsertionEvent {
type: 'programmatic-insertion';
timestamp: number;
insertedText: string; // Text that was inserted
insertedLength: number; // Character count
position: number; // Cursor position where text was inserted
isTrusted: boolean; // Whether the triggering input event was user-generated
inputType?: string; // InputEvent.inputType if available
inputSource?: InputSource; // Input source classification, set by editor integrations or beforeinput fallback
}
```
WriteTrack monitors text length changes against keystroke count. When text grows faster than keystrokes can explain, a programmatic insertion event is emitted.
### Composition Events
[Section titled “Composition Events”](#composition-events)
Captures completed IME composition sequences ([CJK](https://en.wikipedia.org/wiki/CJK_characters) input methods, dead keys, accent input). Intermediate keystrokes during composition are suppressed — only the final result is recorded.
```typescript
interface CompositionEvent {
timestamp: number; // When the composition started
duration: number; // Duration of the composition sequence (ms)
insertedLength: number; // Characters inserted (0 if composition was abandoned)
isTrusted: boolean; // Whether the compositionend event was user-generated
}
```
## Output Format
[Section titled “Output Format”](#output-format)
### Raw Data Access
[Section titled “Raw Data Access”](#raw-data-access)
Access captured events directly:
```typescript
const keystrokes = tracker.getRawEvents();
const clipboard = tracker.getClipboardEvents();
const selections = tracker.getSelectionEvents();
const undoRedo = tracker.getUndoRedoEvents();
const insertions = tracker.getProgrammaticInsertionEvents();
const compositions = tracker.getCompositionEvents();
```
### Session Data Export
[Section titled “Session Data Export”](#session-data-export)
`getData()` returns the complete session as a `WriteTrackDataSchema` object:
```typescript
const data = tracker.getData();
// {
// version: "2.1.0",
// metadata: { tool, targetElement, timestamp, duration },
// session: { events, clipboardEvents, selectionEvents, ... },
// quality: { overallScore, sequenceValid, qualityLevel, ... },
// }
```
## Special Events
[Section titled “Special Events”](#special-events)
### Focus Events
[Section titled “Focus Events”](#focus-events)
Window focus changes are recorded as keystroke events:
```typescript
{
key: 'FOCUS', // or 'BLUR'
code: 'FOCUS', // or 'BLUR'
type: 'keydown',
timestamp: 1234567890.12,
windowFocused: true, // or false
timeSinceLastMouse: 0
}
```
### Visibility Changes
[Section titled “Visibility Changes”](#visibility-changes)
Document visibility changes (tab switching):
```typescript
{
key: 'VISIBILITY_CHANGE',
code: 'VISIBILITY_CHANGE',
type: 'keydown',
timestamp: 1234567890.12,
windowFocused: false,
timeSinceLastMouse: 1234.5,
dwellTime: 5000 // Duration of visibility
}
```
## Timing Precision
[Section titled “Timing Precision”](#timing-precision)
WriteTrack uses `performance.now()` for high-precision timestamps:
* Resolution: Sub-millisecond (typically 5μs)
* Monotonic: Always increasing, not affected by system clock changes
* Relative: Starts at 0 when the page loads
```typescript
// Convert to absolute time if needed
const absoluteTime = Date.now() - performance.now() + event.timestamp;
```
Caution
Some browsers reduce `performance.now()` precision in cross-origin iframes for security reasons. If running WriteTrack in an iframe, verify timing precision is adequate for your use case.
# Event-Detail Charts
> Visualizing WriteTrack Capture Data
Event-detail charts consume raw [`WriteTrackDataSchema`](/docs/api-reference/#writetrackdataschema) from `getData()`. They work without WASM — no analysis pipeline needed.
## Usage
[Section titled “Usage”](#usage)
All event-detail charts follow the same pattern — import, register once, then pass capture data:
```typescript
import { Sparkline } from 'writetrack/viz';
Sparkline.register(); // call once per component
document.querySelector('wt-sparkline').setData(tracker.getData());
```
## Sparkline ``
[Section titled “Sparkline \”](#sparkline-wt-sparkline)
Compact speed chart with no axes or labels — designed for inline use. Recommended size: `200px × 40px`.
You can also create a Sparkline programmatically without a pre-existing element:
```typescript
const sparkline = Sparkline.create(containerElement, tracker.getData());
```
## Document Growth ``
[Section titled “Document Growth \”](#document-growth-wt-document-growth)
Cumulative character count over time. The slope shows typing speed; vertical jumps indicate paste events, labeled with the character count pasted. Recommended size: `width: 100%; height: 200px`.
## Edit Beeswarm ``
[Section titled “Edit Beeswarm \”](#edit-beeswarm-wt-edit-beeswarm)
Keystroke-level beeswarm showing every keypress as a dot, dodge-stacked over time. Paste and cut events appear as larger prominent dots. Density indicates typing speed; gaps indicate pauses. Recommended size: `width: 100%; height: 160px`.
## Edit Waterfall ``
[Section titled “Edit Waterfall \”](#edit-waterfall-wt-edit-waterfall)
Cursor position over time. Diagonal lines show sequential typing; jumps indicate navigation. Cyan dots mark paste events. The Y-axis is reversed so position 0 (start of document) is at the top. Recommended size: `width: 100%; height: 200px`.
## Corrections Bubble ``
[Section titled “Corrections Bubble \”](#corrections-bubble-wt-corrections-bubble)
Edit runs visualized as bubbles — green for insertions, red for deletions, cyan for pastes. Size is proportional to log₂(character count). Consecutive same-type actions are grouped into runs. Recommended size: `width: 100%; height: 80px`.
# CKEditor 5 Integration
> Using WriteTrack with CKEditor 5 rich text editor
WriteTrack includes a first-party CKEditor 5 plugin, compatible with the unified `ckeditor5` package v42+ and tested against v47.5.
## Installation
[Section titled “Installation”](#installation)
Install WriteTrack alongside CKEditor 5:
* npm
```sh
npm i writetrack ckeditor5
```
* pnpm
```sh
pnpm add writetrack ckeditor5
```
* yarn
```sh
yarn add writetrack ckeditor5
```
* bun
```sh
bun add writetrack ckeditor5
```
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```ts
import { ClassicEditor, Essentials, Paragraph, Bold, Italic } from 'ckeditor5';
import { WriteTrackPlugin } from 'writetrack/ckeditor';
ClassicEditor.create(document.querySelector('#editor'), {
plugins: [Essentials, Paragraph, Bold, Italic, WriteTrackPlugin],
writetrack: {
license: 'your-license-key',
},
}).then((editor) => {
// Access WriteTrack data when needed
const plugin = editor.plugins.get('WriteTrack');
const data = plugin.tracker.getData();
});
```
## Configuration
[Section titled “Configuration”](#configuration)
Pass options via the `writetrack` config key:
```ts
ClassicEditor.create(element, {
plugins: [, /* ... */ WriteTrackPlugin],
writetrack: {
license: 'your-license-key',
userId: 'user-123',
contentId: 'document-456',
metadata: { formName: 'signup' },
autoStart: true, // default
},
});
```
| Option | Type | Default | Description |
| ----------- | ------------------------- | ----------- | ----------------------------------------------------------- |
| `license` | `string` | `undefined` | WriteTrack license key |
| `userId` | `string` | `undefined` | User identifier included in metadata |
| `contentId` | `string` | `undefined` | Content identifier included in metadata |
| `metadata` | `Record` | `undefined` | Additional metadata |
| `autoStart` | `boolean` | `true` | Start tracking when editor is ready |
| `wasmUrl` | `string` | `undefined` | URL to WASM binary for analysis |
| `persist` | `boolean` | `false` | Enable IndexedDB session persistence (requires `contentId`) |
## Accessing Data
[Section titled “Accessing Data”](#accessing-data)
The plugin is retrieved via CKEditor’s plugin system:
```ts
const plugin = editor.plugins.get('WriteTrack');
// Get the tracker instance
const tracker = plugin.tracker;
// Get typing data
const data = tracker.getData();
// Check tracking status
const isTracking = plugin.isTracking;
```
## Manual DOM Attachment
[Section titled “Manual DOM Attachment”](#manual-dom-attachment)
If you prefer not to use the plugin, you can attach WriteTrack directly to CKEditor’s editable element:
```ts
import WriteTrack from 'writetrack';
import { ClassicEditor, Essentials, Paragraph } from 'ckeditor5';
ClassicEditor.create(document.querySelector('#editor'), {
plugins: [Essentials, Paragraph],
}).then((editor) => {
// Wait for UI to be ready, then attach
editor.ui.once('ready', () => {
const element = editor.ui.getEditableElement();
const tracker = new WriteTrack({
target: element,
license: 'your-license-key',
});
tracker.start();
});
});
```
Note
The editable element is obtained via `editor.ui.getEditableElement()` — a `
` with `contenteditable="true"`. You must wait for the `ready` event since the element doesn’t exist during `create()`.
## Cleanup
[Section titled “Cleanup”](#cleanup)
The plugin automatically stops tracking and cleans up when the editor is destroyed:
```ts
await editor.destroy(); // WriteTrack is stopped automatically
```
## TypeScript
[Section titled “TypeScript”](#typescript)
```ts
import type { WriteTrackPluginOptions } from 'writetrack/ckeditor';
```
# Lexical Integration
> Using WriteTrack with Meta's Lexical text editor
WriteTrack includes a first-party Lexical integration, compatible with Lexical v0.12+.
## Installation
[Section titled “Installation”](#installation)
Install WriteTrack alongside Lexical:
* npm
```sh
npm i writetrack lexical
```
* pnpm
```sh
pnpm add writetrack lexical
```
* yarn
```sh
yarn add writetrack lexical
```
* bun
```sh
bun add writetrack lexical
```
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```ts
import { createEditor, $getSelection } from 'lexical';
import { createWriteTrackLexical } from 'writetrack/lexical';
const editor = createEditor({ namespace: 'MyEditor', onError: console.error });
editor.setRootElement(document.getElementById('editor'));
const handle = createWriteTrackLexical(editor, $getSelection, {
license: 'your-license-key',
});
// Access WriteTrack data when needed
const data = handle.tracker?.getData();
```
## Configuration
[Section titled “Configuration”](#configuration)
Pass `$getSelection` as the second argument and options as the third:
```ts
const handle = createWriteTrackLexical(editor, $getSelection, {
license: 'your-license-key',
userId: 'user-123',
contentId: 'document-456',
metadata: { formName: 'signup' },
autoStart: true, // default
});
```
| Option | Type | Default | Description |
| ----------- | ------------------------- | ----------- | ----------------------------------------------------------- |
| `license` | `string` | `undefined` | WriteTrack license key |
| `userId` | `string` | `undefined` | User identifier included in metadata |
| `contentId` | `string` | `undefined` | Content identifier included in metadata |
| `metadata` | `Record` | `undefined` | Additional metadata |
| `autoStart` | `boolean` | `true` | Start tracking when root element is available |
| `wasmUrl` | `string` | `undefined` | URL to WASM binary for analysis |
| `persist` | `boolean` | `false` | Enable IndexedDB session persistence (requires `contentId`) |
## Accessing Data
[Section titled “Accessing Data”](#accessing-data)
```ts
// Get the tracker instance (null until root element is available)
const tracker = handle.tracker;
// Get typing data
const data = tracker?.getData();
// Check tracking status
const isTracking = handle.isTracking;
```
## Manual DOM Attachment
[Section titled “Manual DOM Attachment”](#manual-dom-attachment)
If you prefer not to use the integration, you can attach WriteTrack directly to the root element:
```ts
import WriteTrack from 'writetrack';
import { createEditor } from 'lexical';
const editor = createEditor({ namespace: 'MyEditor', onError: console.error });
const rootElement = document.getElementById('editor')!;
editor.setRootElement(rootElement);
const tracker = new WriteTrack({
target: rootElement,
license: 'your-license-key',
});
tracker.start();
```
## Cleanup
[Section titled “Cleanup”](#cleanup)
Call `destroy()` to stop tracking and release resources:
```ts
handle.destroy();
```
The integration also automatically cleans up if the root element is removed from the editor.
## TypeScript
[Section titled “TypeScript”](#typescript)
```ts
import type {
WriteTrackLexicalOptions,
WriteTrackLexicalHandle,
} from 'writetrack/lexical';
```
# ProseMirror Integration
> Using WriteTrack with ProseMirror editor framework
WriteTrack includes a first-party ProseMirror plugin, compatible with `prosemirror-state` v1.0+ and tested against v1.4.
## Installation
[Section titled “Installation”](#installation)
Install WriteTrack alongside your ProseMirror packages:
* npm
```sh
npm i writetrack prosemirror-state prosemirror-view prosemirror-model
```
* pnpm
```sh
pnpm add writetrack prosemirror-state prosemirror-view prosemirror-model
```
* yarn
```sh
yarn add writetrack prosemirror-state prosemirror-view prosemirror-model
```
* bun
```sh
bun add writetrack prosemirror-state prosemirror-view prosemirror-model
```
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```ts
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { schema } from 'prosemirror-schema-basic';
import { WriteTrackPlugin, WriteTrackPluginKey } from 'writetrack/prosemirror';
const state = EditorState.create({
schema,
plugins: [
WriteTrackPlugin({
license: 'your-license-key',
}),
],
});
const view = new EditorView(document.querySelector('#editor'), { state });
// Access WriteTrack data when needed
const pluginState = WriteTrackPluginKey.getState(view.state);
const data = pluginState.tracker.getData();
```
## Configuration
[Section titled “Configuration”](#configuration)
Pass options to the `WriteTrackPlugin()` function:
```ts
WriteTrackPlugin({
license: 'your-license-key',
userId: 'user-123',
contentId: 'document-456',
metadata: { formName: 'signup' },
autoStart: true, // default
});
```
| Option | Type | Default | Description |
| ----------- | ------------------------- | ----------- | ----------------------------------------------------------- |
| `license` | `string` | `undefined` | WriteTrack license key |
| `userId` | `string` | `undefined` | User identifier included in metadata |
| `contentId` | `string` | `undefined` | Content identifier included in metadata |
| `metadata` | `Record` | `undefined` | Additional metadata |
| `autoStart` | `boolean` | `true` | Start tracking when view is created |
| `wasmUrl` | `string` | `undefined` | URL to WASM binary for analysis |
| `persist` | `boolean` | `false` | Enable IndexedDB session persistence (requires `contentId`) |
## Accessing Data
[Section titled “Accessing Data”](#accessing-data)
Use the `WriteTrackPluginKey` to read plugin state from the editor:
```ts
import { WriteTrackPluginKey } from 'writetrack/prosemirror';
const pluginState = WriteTrackPluginKey.getState(view.state);
// Get the tracker instance
const tracker = pluginState.tracker;
// Get typing data
const data = tracker.getData();
// Check tracking status
const isTracking = pluginState.isTracking;
```
## Manual DOM Attachment
[Section titled “Manual DOM Attachment”](#manual-dom-attachment)
If you prefer not to use the plugin, you can attach WriteTrack directly to ProseMirror’s DOM element:
```ts
import WriteTrack from 'writetrack';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { schema } from 'prosemirror-schema-basic';
const state = EditorState.create({ schema });
const view = new EditorView(document.querySelector('#editor'), { state });
const tracker = new WriteTrack({
target: view.dom,
license: 'your-license-key',
});
tracker.start();
```
Note
The editable element is `view.dom` — a `
` with `contenteditable="true"`. It’s available synchronously after `new EditorView()`.
## Cleanup
[Section titled “Cleanup”](#cleanup)
The plugin automatically stops tracking when the view is destroyed:
```ts
view.destroy(); // WriteTrack is stopped automatically
```
## TypeScript
[Section titled “TypeScript”](#typescript)
```ts
import type {
WriteTrackPluginOptions,
WriteTrackPluginState,
} from 'writetrack/prosemirror';
```
# Quill Integration
> Using WriteTrack with Quill rich text editor
WriteTrack includes a first-party Quill module, compatible with Quill v1.3+ and tested against v2.0.
## Installation
[Section titled “Installation”](#installation)
Install WriteTrack alongside Quill:
* npm
```sh
npm i writetrack quill
```
* pnpm
```sh
pnpm add writetrack quill
```
* yarn
```sh
yarn add writetrack quill
```
* bun
```sh
bun add writetrack quill
```
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```ts
import Quill from 'quill';
import { WriteTrackModule } from 'writetrack/quill';
// Register the module before creating the editor
Quill.register('modules/writetrack', WriteTrackModule);
const quill = new Quill('#editor', {
theme: 'snow',
modules: {
writetrack: {
license: 'your-license-key',
},
},
});
// Access WriteTrack data when needed
const mod = quill.getModule('writetrack');
const data = mod.tracker.getData();
```
## Configuration
[Section titled “Configuration”](#configuration)
Pass options via the `writetrack` module config:
```ts
const quill = new Quill('#editor', {
theme: 'snow',
modules: {
writetrack: {
license: 'your-license-key',
userId: 'user-123',
contentId: 'document-456',
metadata: { formName: 'signup' },
autoStart: true, // default
},
},
});
```
| Option | Type | Default | Description |
| ----------- | ------------------------- | ----------- | ----------------------------------------------------------- |
| `license` | `string` | `undefined` | WriteTrack license key |
| `userId` | `string` | `undefined` | User identifier included in metadata |
| `contentId` | `string` | `undefined` | Content identifier included in metadata |
| `metadata` | `Record` | `undefined` | Additional metadata |
| `autoStart` | `boolean` | `true` | Start tracking when module is created |
| `wasmUrl` | `string` | `undefined` | URL to WASM binary for analysis |
| `persist` | `boolean` | `false` | Enable IndexedDB session persistence (requires `contentId`) |
## Accessing Data
[Section titled “Accessing Data”](#accessing-data)
Retrieve the module via Quill’s module system:
```ts
const mod = quill.getModule('writetrack');
// Get the tracker instance
const tracker = mod.tracker;
// Get typing data
const data = tracker.getData();
// Check tracking status
const isTracking = mod.isTracking;
```
## Manual DOM Attachment
[Section titled “Manual DOM Attachment”](#manual-dom-attachment)
If you prefer not to use the module, you can attach WriteTrack directly to Quill’s editable element:
```ts
import WriteTrack from 'writetrack';
import Quill from 'quill';
const quill = new Quill('#editor', { theme: 'snow' });
const tracker = new WriteTrack({
target: quill.root,
license: 'your-license-key',
});
tracker.start();
```
Note
The editable element is `quill.root` — a `
` with `contenteditable="true"`. It’s available synchronously after `new Quill()`.
## Cleanup
[Section titled “Cleanup”](#cleanup)
Quill does not have a built-in destroy lifecycle, so the WriteTrack module’s `destroy()` method must be called manually before unmounting the editor:
```ts
const mod = quill.getModule('writetrack');
mod.destroy();
```
## TypeScript
[Section titled “TypeScript”](#typescript)
```ts
import type { WriteTrackModuleOptions } from 'writetrack/quill';
```
# Slate Integration
> Using WriteTrack with the Slate rich text framework
WriteTrack includes a first-party Slate integration, compatible with Slate v0.94+.
## Installation
[Section titled “Installation”](#installation)
Install WriteTrack alongside Slate:
* npm
```sh
npm i writetrack slate slate-react
```
* pnpm
```sh
pnpm add writetrack slate slate-react
```
* yarn
```sh
yarn add writetrack slate slate-react
```
* bun
```sh
bun add writetrack slate slate-react
```
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```tsx
import { useEffect, useMemo, useRef } from 'react';
import { createEditor } from 'slate';
import { Slate, Editable, withReact, ReactEditor } from 'slate-react';
import { createWriteTrackSlate } from 'writetrack/slate';
function Editor() {
const editor = useMemo(() => withReact(createEditor()), []);
const handleRef = useRef(null);
useEffect(() => {
const domNode = ReactEditor.toDOMNode(editor, editor);
const handle = createWriteTrackSlate(editor, domNode, {
license: 'your-license-key',
});
handleRef.current = handle;
return () => handle.destroy();
}, [editor]);
return (
);
}
```
## Configuration
[Section titled “Configuration”](#configuration)
Pass options as the third argument:
```ts
const handle = createWriteTrackSlate(editor, domNode, {
license: 'your-license-key',
userId: 'user-123',
contentId: 'document-456',
metadata: { formName: 'signup' },
autoStart: true, // default
});
```
| Option | Type | Default | Description |
| ----------- | ------------------------- | ----------- | ----------------------------------------------------------- |
| `license` | `string` | `undefined` | WriteTrack license key |
| `userId` | `string` | `undefined` | User identifier included in metadata |
| `contentId` | `string` | `undefined` | Content identifier included in metadata |
| `metadata` | `Record` | `undefined` | Additional metadata |
| `autoStart` | `boolean` | `true` | Start tracking when created |
| `wasmUrl` | `string` | `undefined` | URL to WASM binary for analysis |
| `persist` | `boolean` | `false` | Enable IndexedDB session persistence (requires `contentId`) |
## Accessing Data
[Section titled “Accessing Data”](#accessing-data)
```ts
// Get the tracker instance
const tracker = handle.tracker;
// Get typing data
const data = tracker?.getData();
// Check tracking status
const isTracking = handle.isTracking;
```
## Cleanup
[Section titled “Cleanup”](#cleanup)
Slate does not have a built-in destroy lifecycle, so `destroy()` must be called manually — typically in a React `useEffect` cleanup:
```ts
useEffect(() => {
const handle = createWriteTrackSlate(editor, domNode, options);
return () => handle.destroy();
}, [editor]);
```
## TypeScript
[Section titled “TypeScript”](#typescript)
```ts
import type {
WriteTrackSlateOptions,
WriteTrackSlateHandle,
} from 'writetrack/slate';
```
# TinyMCE Integration
> Using WriteTrack with TinyMCE rich text editor
WriteTrack includes a first-party TinyMCE integration, compatible with TinyMCE v6+.
## Installation
[Section titled “Installation”](#installation)
Install WriteTrack alongside TinyMCE:
* npm
```sh
npm i writetrack tinymce
```
* pnpm
```sh
pnpm add writetrack tinymce
```
* yarn
```sh
yarn add writetrack tinymce
```
* bun
```sh
bun add writetrack tinymce
```
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```ts
import tinymce from 'tinymce';
import { createWriteTrackTinyMCE } from 'writetrack/tinymce';
tinymce.init({
selector: '#editor',
inline: true, // recommended for full WriteTrack support
setup: (editor) => {
const handle = createWriteTrackTinyMCE(editor, {
license: 'your-license-key',
});
// Access WriteTrack data after editor init
editor.on('init', () => {
const data = handle.tracker?.getData();
});
},
});
```
Inline mode recommended
TinyMCE’s default iframe mode isolates the editor in a separate document context. WriteTrack’s focus and selection tracking relies on `window` and `document` listeners, which only work correctly when the editor is in the same document.
Use `inline: true` in your TinyMCE configuration for full WriteTrack support. In iframe mode, keystroke and clipboard tracking will work, but focus loss detection and selection change tracking may be degraded.
## Configuration
[Section titled “Configuration”](#configuration)
Pass options as the second argument:
```ts
const handle = createWriteTrackTinyMCE(editor, {
license: 'your-license-key',
userId: 'user-123',
contentId: 'document-456',
metadata: { formName: 'signup' },
autoStart: true, // default
});
```
| Option | Type | Default | Description |
| ----------- | ------------------------- | ----------- | ----------------------------------------------------------- |
| `license` | `string` | `undefined` | WriteTrack license key |
| `userId` | `string` | `undefined` | User identifier included in metadata |
| `contentId` | `string` | `undefined` | Content identifier included in metadata |
| `metadata` | `Record` | `undefined` | Additional metadata |
| `autoStart` | `boolean` | `true` | Start tracking when editor initializes |
| `wasmUrl` | `string` | `undefined` | URL to WASM binary for analysis |
| `persist` | `boolean` | `false` | Enable IndexedDB session persistence (requires `contentId`) |
## Accessing Data
[Section titled “Accessing Data”](#accessing-data)
The tracker is available after TinyMCE’s `init` event fires:
```ts
tinymce.init({
selector: '#editor',
inline: true,
setup: (editor) => {
const handle = createWriteTrackTinyMCE(editor, {
license: 'your-license-key',
});
editor.on('init', () => {
const tracker = handle.tracker;
const data = tracker?.getData();
const isTracking = handle.isTracking;
});
},
});
```
## Manual DOM Attachment
[Section titled “Manual DOM Attachment”](#manual-dom-attachment)
If you prefer not to use the integration, you can attach WriteTrack directly in inline mode:
```ts
import WriteTrack from 'writetrack';
import tinymce from 'tinymce';
tinymce.init({
selector: '#editor',
inline: true,
setup: (editor) => {
editor.on('init', () => {
const tracker = new WriteTrack({
target: editor.getBody(),
license: 'your-license-key',
});
tracker.start();
});
},
});
```
## Cleanup
[Section titled “Cleanup”](#cleanup)
The integration automatically cleans up when TinyMCE’s `remove` event fires. You can also call `destroy()` manually:
```ts
handle.destroy();
```
## TypeScript
[Section titled “TypeScript”](#typescript)
```ts
import type {
WriteTrackTinyMCEOptions,
WriteTrackTinyMCEHandle,
} from 'writetrack/tinymce';
```
# TipTap Integration
> Using WriteTrack with TipTap rich text editor
WriteTrack includes a first-party TipTap extension, compatible with `@tiptap/core` v2.0+ and tested against v3.19. See [React](#react) and [Vue](#vue) examples below.
## Installation
[Section titled “Installation”](#installation)
Install WriteTrack alongside your TipTap packages:
* npm
```sh
npm i writetrack @tiptap/core @tiptap/starter-kit
```
* pnpm
```sh
pnpm add writetrack @tiptap/core @tiptap/starter-kit
```
* yarn
```sh
yarn add writetrack @tiptap/core @tiptap/starter-kit
```
* bun
```sh
bun add writetrack @tiptap/core @tiptap/starter-kit
```
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```ts
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { WriteTrackExtension } from 'writetrack/tiptap';
const editor = new Editor({
element: document.querySelector('#editor'),
extensions: [
StarterKit,
WriteTrackExtension.configure({
license: 'your-license-key',
}),
],
});
// Access WriteTrack data when needed
const data = editor.storage.writetrack.tracker.getData();
```
## Configuration
[Section titled “Configuration”](#configuration)
Pass options via `.configure()`:
```ts
WriteTrackExtension.configure({
license: 'your-license-key',
userId: 'user-123',
contentId: 'document-456',
metadata: { formName: 'signup' },
autoStart: true, // default
});
```
| Option | Type | Default | Description |
| ----------- | ------------------------- | ----------- | ----------------------------------------------------------- |
| `license` | `string` | `undefined` | WriteTrack license key |
| `userId` | `string` | `undefined` | User identifier included in metadata |
| `contentId` | `string` | `undefined` | Content identifier included in metadata |
| `metadata` | `Record` | `undefined` | Additional metadata |
| `autoStart` | `boolean` | `true` | Start tracking when editor is created |
| `wasmUrl` | `string` | `undefined` | URL to WASM binary for analysis |
| `persist` | `boolean` | `false` | Enable IndexedDB session persistence (requires `contentId`) |
## Accessing Data
[Section titled “Accessing Data”](#accessing-data)
The extension stores the WriteTrack instance in TipTap’s storage system:
```ts
// Get the tracker instance
const tracker = editor.storage.writetrack.tracker;
// Get typing data
const data = tracker.getData();
// Check tracking status
const isTracking = editor.storage.writetrack.isTracking;
```
## React
[Section titled “React”](#react)
```tsx
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { WriteTrackExtension } from 'writetrack/tiptap';
const writetrack = WriteTrackExtension.configure({
license: 'your-license-key',
});
function FormEditor() {
const editor = useEditor({
extensions: [StarterKit, writetrack],
});
const handleSubmit = () => {
const data = editor?.storage.writetrack.tracker?.getData();
console.log('Typing data:', data);
};
return (
```
## Manual DOM Attachment
[Section titled “Manual DOM Attachment”](#manual-dom-attachment)
If you prefer not to use the extension, you can attach WriteTrack directly to TipTap’s editable element:
```ts
import WriteTrack from 'writetrack';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
const editor = new Editor({
element: document.querySelector('#editor'),
extensions: [StarterKit],
onCreate({ editor }) {
const tracker = new WriteTrack({
target: editor.view.dom,
license: 'your-license-key',
});
tracker.start();
},
});
```
Note
The editable element is `editor.view.dom` — a `
` with `contenteditable="true"` managed by ProseMirror under the hood.
## TypeScript
[Section titled “TypeScript”](#typescript)
```ts
import type {
WriteTrackExtensionOptions,
WriteTrackExtensionStorage,
} from 'writetrack/tiptap';
```
# Licensing
> License keys for production use
## Getting a License Key
[Section titled “Getting a License Key”](#getting-a-license-key)
You can get a license key from the [web portal](/portal/signup) or via the CLI.
Run the CLI to register a free 28-day trial:
```bash
npx writetrack init
```
The CLI will prompt for your email and production domain, then save the license key to `.env`:
```plaintext
WriteTrack — Set up a free 28-day trial
Email address: you@example.com
Production domain (e.g. example.com): example.com
Registering you@example.com for example.com...
Done — license key saved to .env
Trial expires: 3/10/2026
```
Then pass the key when initializing:
```typescript
const tracker = new WriteTrack({
target: textarea,
license: process.env.WRITETRACK_LICENSE_KEY,
});
```
Tip
The CLI writes `WRITETRACK_LICENSE_KEY` to your `.env` file and checks that `.env` is in `.gitignore`. If you already have a key configured, it will show the existing key instead of registering again.
## Evaluation Mode
[Section titled “Evaluation Mode”](#evaluation-mode)
On localhost, WriteTrack runs with full analysis enabled — no license key required. Use this to evaluate the SDK during development and testing. A license key is required for production deployments.
```typescript
// Evaluation mode — localhost only, no license required
const tracker = new WriteTrack({ target: textarea });
```
# Next.js
> Using WriteTrack with Next.js applications
WriteTrack uses browser APIs and must run client-side only. Here’s how to integrate it with Next.js.
## App Router (Next.js 13+)
[Section titled “App Router (Next.js 13+)”](#app-router-nextjs-13)
Add the `'use client'` directive to components using WriteTrack:
```tsx
'use client';
import { useRef, useEffect } from 'react';
import { WriteTrack } from 'writetrack';
export function ResponseForm() {
const textareaRef = useRef(null);
const trackerRef = useRef(null);
useEffect(() => {
if (textareaRef.current) {
trackerRef.current = new WriteTrack({ target: textareaRef.current });
trackerRef.current.start();
}
return () => {
trackerRef.current?.stop();
};
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (trackerRef.current) {
const data = trackerRef.current.getData();
const events = trackerRef.current.getRawEvents();
console.log(`Captured ${events.length} events`, data.quality);
}
};
return (
);
}
```
## Using the React Hook
[Section titled “Using the React Hook”](#using-the-react-hook)
The `writetrack/react` hook handles client-side concerns automatically:
```tsx
'use client';
import { useRef } from 'react';
import { useWriteTrack } from 'writetrack/react';
export function ResponseForm() {
const textareaRef = useRef(null);
const { isTracking, tracker } = useWriteTrack(textareaRef);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (tracker) {
const data = tracker.getData();
console.log('Session:', data.quality);
}
};
return (
);
}
```
## Pages Router
[Section titled “Pages Router”](#pages-router)
For the Pages Router, either use the `'use client'` approach above, or use dynamic imports:
```tsx
import dynamic from 'next/dynamic';
const ResponseForm = dynamic(() => import('../components/ResponseForm'), {
ssr: false,
});
export default function Page() {
return ;
}
```
## Server Actions
[Section titled “Server Actions”](#server-actions)
Send captured data to Server Actions:
```tsx
'use client';
import { useRef } from 'react';
import { useWriteTrack } from 'writetrack/react';
import { submitResponse } from './actions';
export function ResponseForm() {
const textareaRef = useRef(null);
const { tracker } = useWriteTrack(textareaRef);
async function handleSubmit(formData: FormData) {
if (tracker) {
formData.set('keystrokeCount', String(tracker.getKeystrokeCount()));
}
await submitResponse(formData);
}
return (
);
}
```
actions.ts
```typescript
'use server';
export async function submitResponse(formData: FormData) {
const content = formData.get('content') as string;
const keystrokeCount = formData.get('keystrokeCount') as string;
// Store or process the submission
await db.responses.create({
content,
keystrokeCount: Number(keystrokeCount),
});
}
```
## API Routes
[Section titled “API Routes”](#api-routes)
Alternatively, send data to API routes:
```tsx
const handleSubmit = async () => {
if (!tracker) return;
const data = tracker.getData();
await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: textareaRef.current?.value,
typingData: data,
}),
});
};
```
# Output Sinks
> Route WriteTrack session data to your existing systems with .pipe()
Output sinks route session data from `getData()` to external destinations like webhooks, analytics platforms, or custom backends.
**Available sinks:** [Webhook](/docs/sink-webhook/) | [Datadog](/docs/sink-datadog/) | [Segment](/docs/sink-segment/) | [OpenTelemetry](/docs/sink-opentelemetry/) | [Custom](/docs/sink-custom/)
## How It Works
[Section titled “How It Works”](#how-it-works)
```typescript
import { WriteTrack, webhook } from 'writetrack';
const tracker = new WriteTrack({
target: textarea,
userId: 'u_abc123',
contentId: 'post_draft_42',
});
tracker.pipe(webhook({ url: 'https://api.example.com/typing' }));
tracker.start();
// Data flows to all registered sinks on each getData() call
const data = tracker.getData(); // also dispatches to webhook
```
### Data Flow
[Section titled “Data Flow”](#data-flow)
Sinks receive data when `getData()` is called — typically on form submit or when you’re done capturing. Every `getData()` call dispatches to all registered sinks. There is no once-only guard — if you call `getData()` three times, sinks receive data three times.
### Sink Lifecycle
[Section titled “Sink Lifecycle”](#sink-lifecycle)
1. **Registration** — Call `.pipe(sink)` to register a sink. You can register multiple sinks, and `.pipe()` returns `this` for chaining.
2. **Dispatch** — When `getData()` is called, all registered sinks receive the full `WriteTrackDataSchema` object in parallel.
3. **Isolation** — Each sink runs independently. If one sink fails, the others still receive data.
4. **Error handling** — Failed sinks emit a `pipe:error` event.
```typescript
// Register multiple sinks
tracker
.pipe(webhook({ url: 'https://api.example.com/typing' }))
.pipe(webhook({ url: 'https://backup.example.com/typing' }));
// Handle errors
tracker.on('pipe:error', (err, sink) => {
console.error('Sink failed:', err);
});
```
## Context Fields Optional
[Section titled “Context Fields ”](#context-fields)
Pass context fields in the constructor to tag sessions with user and content identifiers. These flow through to `getData()` output and to all sinks.
```typescript
const tracker = new WriteTrack({
target: textarea,
license: 'wt_live_...',
userId: 'u_abc123', // who is typing
contentId: 'post_draft_42', // what they're typing into
metadata: { formName: 'signup' }, // arbitrary tags
});
```
| Field | Type | Description |
| ----------- | ------------------------- | --------------------------------------------------------- |
| `userId` | `string` | User identifier. Passed through to sinks as-is. |
| `contentId` | `string` | Content or document identifier. |
| `metadata` | `Record` | Arbitrary key-value pairs. Appears as `custom` in output. |
These fields appear in the `metadata` section of `getData()` output:
```json
{
"version": "2.1.0",
"metadata": {
"tool": { "name": "writetrack", "version": "0.5.0" },
"targetElement": "textarea",
"timestamp": "2026-02-11T12:00:00.000Z",
"duration": 45000,
"userId": "u_abc123",
"contentId": "post_draft_42",
"custom": { "formName": "signup" }
}
}
```
## The WriteTrackSink Interface
[Section titled “The WriteTrackSink Interface”](#the-writetracksink-interface)
A sink is any object with a `send` method:
```typescript
interface WriteTrackSink {
send(data: WriteTrackDataSchema): Promise;
}
```
WriteTrack ships named sink factories for common destinations. You can also pass any object that implements this interface.
## Available Sinks
[Section titled “Available Sinks”](#available-sinks)
| Sink | Destination | Min version |
| ------------------------------------------ | ----------------------- | --------------------------------- |
| [Webhook](/docs/sink-webhook/) | Any HTTP endpoint | — |
| [Datadog](/docs/sink-datadog/) | Datadog RUM | `@datadog/browser-rum >=3.0.0` |
| [Segment](/docs/sink-segment/) | Segment Analytics | `@segment/analytics-next >=1.8.0` |
| [OpenTelemetry](/docs/sink-opentelemetry/) | OpenTelemetry traces | `@opentelemetry/api >=1.0.0` |
| [Custom](/docs/sink-custom/) | Your own implementation | — |
## Error Handling
[Section titled “Error Handling”](#error-handling)
Listen for errors with the `pipe:error` event:
```typescript
tracker.on('pipe:error', (err, sink) => {
// err: the Error thrown or rejected by the sink
// sink: the WriteTrackSink that failed
console.error('Sink delivery failed:', err);
});
```
Sink errors are caught via promise rejection and emitted as `pipe:error` events. Since the `send` method returns a `Promise`, throwing inside an `async send()` produces a rejected promise that WriteTrack handles automatically.
## Next Steps
[Section titled “Next Steps”](#next-steps)
* [Webhook Sink](/docs/sink-webhook/) — Send data to any HTTP endpoint
* [Datadog](/docs/sink-datadog/) — Send to Datadog RUM
* [Segment](/docs/sink-segment/) — Send to Segment Analytics
* [OpenTelemetry](/docs/sink-opentelemetry/) — Send as OpenTelemetry spans
* [Custom Sink](/docs/sink-custom/) — Build your own sink for any backend
# Session Persistence
> Save and restore typing sessions across page reloads with IndexedDB
By default, session data lives only in memory and is lost on page reload. Enable persistence to automatically save and restore sessions via IndexedDB.
## Setup
[Section titled “Setup”](#setup)
Pass `persist: true` with a `contentId` to the constructor:
```typescript
const tracker = new WriteTrack({
target: textarea,
contentId: 'post_draft_42', // Required with persist
persist: true,
});
// Wait for any prior session data to load from IndexedDB
await tracker.ready;
// start() will auto-resume the prior session if one exists
tracker.start();
```
When a session is resumed, all previously captured events are restored and new events are appended. The `sessionId` stays the same across resumes, and a `segment` counter in the metadata tracks how many times the session was resumed.
## How It Works
[Section titled “How It Works”](#how-it-works)
* **Storage key**: Sessions are stored by `contentId:fieldId`, where `fieldId` is derived from `data-writetrack-field`, `id`, or `name` on the target element.
* **Page unload**: On `beforeunload`, the current session is synchronously saved to `localStorage` as a fallback (IndexedDB transactions don’t survive page unload). On the next load, this is promoted back to IndexedDB.
* **`ready` promise**: Resolves when IndexedDB has loaded any prior session. Always `await tracker.ready` before calling `start()` when persistence is enabled.
* **`stop()`**: Saves the session to IndexedDB. After calling `stop()`, `ready` resolves again once the save completes.
* **Cleanup**: Call `clearPersistedData()` to remove the stored session for this field (e.g., after successful form submission).
## Multi-Field Forms
[Section titled “Multi-Field Forms”](#multi-field-forms)
Each field gets its own persisted session, keyed by `contentId:fieldId`. Give each tracked element a distinct `id` or `data-writetrack-field` attribute:
```typescript
const trackers = ['#title', '#body', '#summary'].map((sel) => {
const el = document.querySelector(sel)!;
const t = new WriteTrack({ target: el, contentId: 'post_42', persist: true });
return t;
});
await Promise.all(trackers.map((t) => t.ready));
trackers.forEach((t) => t.start());
```
## Cleanup After Submission
[Section titled “Cleanup After Submission”](#cleanup-after-submission)
After the user submits the form, clear persisted data so the next visit starts fresh:
```typescript
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = tracker.getData();
tracker.stop();
await tracker.ready; // wait for final save
await tracker.clearPersistedData(); // remove from IndexedDB
await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) });
});
```
## Managing Persisted Sessions
[Section titled “Managing Persisted Sessions”](#managing-persisted-sessions)
For multi-document applications, use the static methods to list and delete persisted sessions without creating a WriteTrack instance:
```typescript
// List all persisted sessions
const sessions = await WriteTrack.listPersistedSessions();
// [{ contentId: 'doc_1', fieldId: 'default', keystrokeCount: 42, ... }]
// Delete all sessions for a document (e.g., when user deletes it)
await WriteTrack.deletePersistedSession('doc_1');
// Delete a specific field's session
await WriteTrack.deletePersistedSession('doc_1', 'body');
```
## Related
[Section titled “Related”](#related)
* [`persist` option](/docs/api-reference/#writetrackoptions) — Constructor option reference
* [`ready` promise](/docs/api-reference/#ready) — Await persistence loading
* [`clearPersistedData()`](/docs/api-reference/#clearpersisteddata) — Remove stored sessions
* [`listPersistedSessions()`](/docs/api-reference/#static-listpersistedsessions) — List all stored sessions
* [`deletePersistedSession()`](/docs/api-reference/#static-deletepersistedsession) — Delete sessions by contentId
* [Privacy & Security](/docs/privacy/) — How persistence affects data storage
# Privacy & Security
> What WriteTrack collects, what it doesn't, and how it fits into your security posture
All processing happens client-side. The SDK makes no network requests unless you configure [output sinks](/docs/output-sinks/) — in which case data goes only where you send it.
## What WriteTrack Collects
[Section titled “What WriteTrack Collects”](#what-writetrack-collects)
### Timing Data
[Section titled “Timing Data”](#timing-data)
* **Key press timestamps** — when keys are pressed and released
* **Dwell times** — how long each key is held
* **Flight times** — intervals between keystrokes
* **Mouse activity timing** — time since last mouse movement (not position)
* **Focus events** — when the window gains/loses focus
* **Composition events** — when IME composition sequences complete (duration and character count, not the composed text)
### Event Metadata
[Section titled “Event Metadata”](#event-metadata)
* **Key codes** — which keys were pressed (e.g., `KeyA`, `Backspace`)
* **Key names** — character or key name (e.g., `a`, `Enter`)
* **Cursor position** — position in text where events occurred
* **Selection ranges** — start/end positions of selections
### Content Fields
[Section titled “Content Fields”](#content-fields)
These fields capture actual text content:
* **Clipboard content** — pasted text
* **Selected text** — text that was selected
* **Before/after text states** — text content around edits
Caution
If you store raw event data server-side, content fields may contain personally identifiable information. See [Sensitive Fields](#sensitive-fields) below.
## What WriteTrack Does NOT Collect
[Section titled “What WriteTrack Does NOT Collect”](#what-writetrack-does-not-collect)
* **IP addresses** — no network requests by default
* **User identifiers** — no tracking or fingerprinting
* **Browsing history** — no access to other tabs or pages
* **System information** — no device fingerprinting
* **Persistent state** — no localStorage or cookies. IndexedDB is used only when `persist: true` is explicitly enabled (see [Session Persistence](/docs/persistence/))
## Data Lifecycle
[Section titled “Data Lifecycle”](#data-lifecycle)
By default, data exists only in memory for the duration of a session. Calling `start()` clears previous data. When the tracker is dereferenced, all data is garbage collected.
When `persist: true` is enabled, session data is also stored in IndexedDB so it can be restored after page reloads. Call `clearPersistedData()` to remove persisted data explicitly.
## License Validation
[Section titled “License Validation”](#license-validation)
License keys contain embedded cryptographic signatures and are validated entirely client-side. No network requests are made during validation. Evaluation mode (no license key) works fully offline on localhost.
## Sensitive Fields
[Section titled “Sensitive Fields”](#sensitive-fields)
When exporting data server-side, these are the fields that may contain user-authored text:
| Event type | Sensitive fields |
| ---------------- | ------------------------------------ |
| Keystroke events | `key`, `code` |
| Clipboard events | `content`, `beforeText`, `afterText` |
| Selection events | `selectedText` |
| Undo/redo events | `beforeText`, `afterText` |
| Session export | `session.rawText` |
The `getData().quality` score and all timing fields (`timestamp`, `dwellTime`, `flightTime`) contain no user content and are always safe to store.
## Zero Runtime Dependencies
[Section titled “Zero Runtime Dependencies”](#zero-runtime-dependencies)
The SDK has no npm dependencies — the published package contains only first-party code. There is no transitive dependency tree and no supply chain attack surface.
## Content Security Policy
[Section titled “Content Security Policy”](#content-security-policy)
### Capture Only (`getData()`)
[Section titled “Capture Only (getData())”](#capture-only-getdata)
The capture layer works under strict CSP with no relaxation:
* No `unsafe-eval` — the SDK never uses `eval()` or `new Function()`
* No `unsafe-inline` — no inline styles or scripts are injected
* No workers, blob URLs, or `document.write`
### With WASM Analysis (`getAnalysis()`)
[Section titled “With WASM Analysis (getAnalysis())”](#with-wasm-analysis-getanalysis)
If you call `getAnalysis()` or `getSessionReport()`, the SDK lazy-loads a WebAssembly module. You must add `wasm-unsafe-eval` to your `script-src` directive:
```plaintext
Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval';
```
Capture-only usage (`getData()`) does not require this directive.
### connect-src
[Section titled “connect-src”](#connect-src)
If using the [webhook sink](/docs/sink-webhook/), add your endpoint to `connect-src`.
## Analysis Processing
[Section titled “Analysis Processing”](#analysis-processing)
When using `getAnalysis()`, all analysis runs **entirely client-side** in the browser via WebAssembly. No data is transmitted to Anthropic, WriteTrack, or any third party. The WASM module computes SHA-256 hashes of text content for integrity verification — the hashes are included in the analysis output but the original text is not stored or transmitted by the analysis module.
# Quickstart
> Capture typing behavior from a text input and send it to your server
Install WriteTrack, attach it to a text input, and send captured typing data to your server.
## Installation
[Section titled “Installation”](#installation)
* npm
```sh
npm i writetrack
```
* pnpm
```sh
pnpm add writetrack
```
* yarn
```sh
yarn add writetrack
```
* bun
```sh
bun add writetrack
```
Tip
Using React or Vue? See [React Integration](/docs/react/) or [Vue Integration](/docs/vue/) for framework-specific hooks. First-party plugins are also available for [TipTap](/docs/integrations/tiptap/), [CKEditor 5](/docs/integrations/ckeditor/), [ProseMirror](/docs/integrations/prosemirror/), [Quill](/docs/integrations/quill/), [Lexical](/docs/integrations/lexical/), [Slate](/docs/integrations/slate/), and [TinyMCE](/docs/integrations/tinymce/).
## Capture Typing Data
[Section titled “Capture Typing Data”](#capture-typing-data)
Attach WriteTrack to any `