Skip to main content

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:

typescript
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:

  • id enables deduplication and idempotent processing. If the same event is received twice (network retry, reconnection), consumers can detect and skip duplicates.
  • timestamp is 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.
  • userId ensures strict user isolation. The service never delivers User A's events to User B.
  • channelId determines which WebSocket channel(s) receive the event. Typically "user:{userId}" for per-user channels.
  • source provides 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:

typescript
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.

json
{
  "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 hyperlink
  • form_submit -- Navigation triggered by form submission
  • back_forward -- Browser back/forward buttons
  • reload -- Page refresh
  • redirect -- Server-side redirect (3xx response)
  • history -- JavaScript history.pushState() or history.replaceState()
  • script -- JavaScript-driven navigation (window.location)
json
{
  "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).

json
{
  "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 Typetype ValueWhen 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
import { createPeriscopeEventSchema } from "@lovelace-ai/periscope-core";

const myEventSchema = createPeriscopeEventSchema(MyCustomPayloadSchema);

For branded identifiers, use the factory functions:

typescript
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