Event System
Concepts
The Periscope event system provides a consistent way to represent any observable user activity -- text selections, page navigations, document opens, code editing, media playback, form interactions, and more -- as strongly-typed, validated events. Every event follows the same envelope structure, making it straightforward for consumers to handle any event type through a single code path while still getting full type safety for each specific payload.
The Event Envelope
Every event in Periscope is a PeriscopeEvent. The envelope wraps the payload with metadata needed for routing, tracing, and user isolation:
interface PeriscopeEvent<TPayload extends BasePayload = BasePayload> {
readonly id: EventId; // Unique event identifier (branded string)
readonly timestamp: string; // ISO 8601 timestamp
readonly userId: UserId; // Who generated the event
readonly channelId: ChannelId; // Where to route/broadcast the event
readonly source: EventSource; // Device and application info
readonly payload: TPayload; // The actual event data
}
The generic parameter TPayload lets you work with strongly-typed events when you know the payload kind, or with the base BasePayload type when handling events generically.
Why this structure matters:
idenables deduplication and idempotent processing. If the same event is received twice (network retry, reconnection), consumers can detect and skip duplicates.timestampis set when the event occurs in the browser, not when it reaches the server. This means events can be properly ordered even if they arrive out of sequence due to batching or network delays.userIdensures strict user isolation. The service never delivers User A's events to User B.channelIddetermines which WebSocket channel(s) receive the event. Typically"user:{userId}"for per-user channels.sourceprovides full provenance: which device, which browser, which tab generated the event. This is critical for multi-device scenarios where the same user has Periscope running on multiple machines.
The BasePayload Pattern
All payloads implement the BasePayload generic interface:
interface BasePayload<
TData extends object = object,
TContext extends object = object,
> {
readonly kind: string; // Discriminator: "text.selection", "page.navigation", etc.
readonly data: TData; // What happened
readonly mimeType?: string; // Optional content type hint
readonly context?: TContext; // Surrounding state
}
The separation of data and context is deliberate. The data field contains the primary information about what happened -- the selected text, the new URL, the media playback state. The context field contains surrounding state that helps interpret the data -- the page URL when text was selected, the editor name when code was selected, the form ID when a field was changed.
This separation serves two purposes. First, it makes it clear what is the "signal" versus the "noise" when processing events. An agent that only cares about selected text can look at data.text without wading through page metadata. Second, context can be omitted in bandwidth-constrained scenarios without losing the core event information.
The kind field uses dot notation ("text.selection", "page.navigation") as a namespace convention. This enables pattern matching on categories: you can check if a kind starts with "text." to handle all text-related events, or match exact kinds for specific handling.
Payload Kinds
Periscope defines 10 standard payload kinds. Each models a distinct category of user activity.
1. text.selection
Fired when the user selects text on a page. The most common event type for agents that provide contextual assistance.
{
"kind": "text.selection",
"data": {
"text": "Periscope bridges the gap between browser and agents",
"range": { "start": 142, "end": 195 },
"surrounding": "...context. Periscope bridges the gap between browser and agents. It captures..."
},
"mimeType": "text/plain",
"context": {
"url": "https://uselovelace.com/docs/periscope",
"documentTitle": "Periscope Documentation",
"isMultiline": false
}
}
The surrounding field provides up to 50 characters on each side of the selection, giving agents enough context to understand what the user was looking at without sending the entire page.
2. page.navigation
Fired when the user navigates to a new page. The navigationType field distinguishes between 8 navigation types:
initial-- First page load (new tab, typed URL)link_click-- User clicked a hyperlinkform_submit-- Navigation triggered by form submissionback_forward-- Browser back/forward buttonsreload-- Page refreshredirect-- Server-side redirect (3xx response)history-- JavaScripthistory.pushState()orhistory.replaceState()script-- JavaScript-driven navigation (window.location)
{
"kind": "page.navigation",
"data": {
"url": "https://github.com/lovelace-ai/periscope",
"title": "lovelace-ai/periscope",
"navigationType": "link_click",
"previousUrl": "https://github.com/lovelace-ai",
"statusCode": 200
},
"context": {
"sameSite": true,
"navigationTime": 342
}
}
The distinction between navigation types matters for understanding user intent. A link_click indicates intentional navigation; a redirect does not. An agent tracking research sessions would likely count link_click and initial as meaningful steps but ignore redirect.
3. document.open
Fired when the user opens a document in the browser (PDF viewer, Google Docs, Sheets, etc.). Supports 12 document types: pdf, word, google_docs, google_sheets, spreadsheet, presentation, code, text, markdown, image, video, audio, and other.
4. code.selection
A specialized variant of text selection for code. Includes language detection, line/character ranges, and optional syntax type identification (function, class, comment, string). The context includes file path, repository URL, branch, and editor information.
5. media.playback
Fired when media plays, pauses, stops, or enters buffering. Covers audio, video, podcast, and stream media types. Reports playback state, current position, duration, and playback rate. Context includes title, artist, album, and service (Spotify, YouTube, etc.).
6. image.capture
Fired when the user takes a screenshot or captures an image. Reports the capture type (screenshot, photo, webcam, clipboard, extraction), image data (base64 or URL reference), format, and dimensions.
7. focus.change
Fired when a DOM element gains or loses focus. Reports the element type (one of 9 types: input, button, link, select, checkbox, radio, contenteditable, searchbox, other), whether focus was gained or lost, and the previous element. Useful for understanding which UI elements the user is interacting with.
8. scroll
Fired when the user scrolls a page or element. Reports x/y position, maximum scrollable dimensions, direction, velocity, and scroll percentage. Agents can use scroll percentage to understand reading progress, and velocity to distinguish casual browsing from deliberate reading.
9. cursor.move
Fired when the mouse cursor moves. Reports x/y position, distance traveled since last event, velocity, and the hovered element. This is a high-frequency event -- implementations should use throttling/debouncing to avoid overwhelming the pipeline.
10. form.input
Fired when the user interacts with a form field. Supports 13 input types: text, email, password, number, checkbox, radio, select, textarea, date, time, file, search, and other. Reports the field name, current value (privacy-filtered for sensitive fields), and interaction type (focus, change, blur, submit).
{
"kind": "form.input",
"data": {
"inputType": "email",
"fieldName": "email",
"value": "user@example.com",
"interactionType": "change",
"isRequired": true
},
"context": {
"formId": "signup-form",
"formAction": "https://api.example.com/signup",
"url": "https://example.com/register",
"label": "Email address"
}
}
Event Sources
The source field on a PeriscopeEvent identifies where the event came from. It is a discriminated union on the type field with five variants:
| Source Type | type Value | When Used |
|---|---|---|
| Browser tab | "browser" | Events from web pages in browser tabs. Includes browser name/version and tab info. |
| Desktop app | "desktop_app" | Events from native desktop applications (VS Code, terminal, etc.). Includes app name and window info. |
| Mobile app | "mobile_app" | Events from mobile applications. Includes app name and screen info. |
| CLI | "cli" | Events from command-line tools. Includes command and shell info. |
| Extension | "extension" | Events from browser extensions. Includes extension name/id and browser info. |
Every source variant includes a device field with DeviceInfo:
interface DeviceInfo {
readonly name: string; // "MacBook Pro", "Pixel 7", etc.
readonly os: {
readonly name: string; // "macOS", "Windows", "Linux", etc.
readonly version: string;
};
readonly formFactor:
| "laptop"
| "desktop"
| "tablet"
| "phone"
| "server"
| "other";
readonly arch?: string; // "arm64", "x86_64", etc.
readonly id?: string; // Stable device identifier
}
The formFactor field lets agents adapt their behavior based on device type. An agent that provides UI suggestions might generate different recommendations for a phone versus a desktop.
Type-Safe Event Handling
Because payloads use the kind field as a discriminator, TypeScript can narrow the type in a switch statement:
function handleEvent(event: PeriscopeEvent<StandardPayload>) {
switch (event.payload.kind) {
case "text.selection":
// TypeScript knows event.payload is TextSelectionPayload here
console.log(event.payload.data.text);
break;
case "page.navigation":
// TypeScript knows event.payload is PageNavigationPayload here
console.log(event.payload.data.url);
break;
// ... handle other kinds
}
}
The StandardPayload union type covers all 10 payload kinds. If you only care about a subset, you can define a narrower union:
type MyPayload = TextSelectionPayload | PageNavigationPayload;
function handleMyEvents(event: PeriscopeEvent<MyPayload>) {
// TypeScript will enforce exhaustive matching for only these two kinds
}
Validation
Every type has a corresponding Zod schema. Validation happens at system boundaries -- when the extension sends an event to the service, and when the service sends an event to a client:
import { PeriscopeEventSchema } from "@lovelace-ai/periscope-core";
const result = PeriscopeEventSchema.safeParse(incomingData);
if (result.success) {
processEvent(result.data);
} else {
logger.error("EventValidation", "Invalid event", { errors: result.error });
}
For payload-specific validation, each payload kind has its own schema (e.g., TextSelectionPayloadSchema, PageNavigationPayloadSchema). The StandardPayloadSchema is a discriminated union over kind that validates any of the 10 standard payload types.
Creating Events
Use the factory function createPeriscopeEventSchema to build a schema for a custom event type:
import { createPeriscopeEventSchema } from "@lovelace-ai/periscope-core";
const myEventSchema = createPeriscopeEventSchema(MyCustomPayloadSchema);
For branded identifiers, use the factory functions:
import {
createEventId,
createUserId,
createChannelId,
} from "@lovelace-ai/periscope-core";
const event: PeriscopeEvent<TextSelectionPayload> = {
id: createEventId("evt_abc123"),
timestamp: new Date().toISOString(),
userId: createUserId("user_456"),
channelId: createChannelId("user:456"),
source: {
/* ... */
},
payload: {
/* ... */
},
};
Next Steps
- Browser Context Model -- The data structure that represents captured browser state
- Privacy Model -- How events are filtered for sensitive content
- Real-Time Streaming -- How events flow through WebSocket channels