Skip to main content

Sync Protocol

Concepts

The sync protocol defines how browser context moves from the Periscope extension to the Periscope service. It handles context creation, updates, deletion, tab activation, navigation events, and heartbeats through a structured message format with typed payloads, batching support, and a connection state machine for reliability.

Why a Dedicated Protocol?

Periscope could have used raw HTTP requests for each context change. The sync protocol exists because browser context changes are frequent, latency-sensitive, and sometimes need to be batched. A user rapidly switching tabs or scrolling through search results might generate dozens of context changes per second. Sending each as an independent HTTP request would be wasteful and slow.

The sync protocol provides:

  • Typed messages with discriminated union payloads for compile-time safety
  • Batching to combine multiple changes into a single network request
  • Heartbeats so the service knows the extension is alive even during idle periods
  • Structured error handling with typed error codes and optional retry guidance
  • Connection state tracking for graceful reconnection

SyncMessage

Every message from the extension to the service is a SyncMessage:

typescript
interface SyncMessage {
  readonly id: string; // Unique message identifier
  readonly type: SyncOperationType; // What kind of operation
  readonly timestamp: Date; // When the message was created
  readonly userId: UserId; // Which user
  readonly extensionId: string; // Which browser extension instance
  readonly payload: SyncPayload; // The actual data (discriminated union)
}

The type field indicates the high-level operation category:

OperationWhen Used
createNew context captured (page load, new selection, form interaction)
updateExisting context changed (page content updated, form field changed)
deleteContext no longer valid (tab closed, page navigated away)
activateTab gained focus
deactivateTab lost focus
navigateUser navigated within a tab
heartbeatPeriodic liveness signal

The extensionId field identifies the specific browser extension instance. A user might have Periscope installed in both Chrome and Firefox; the extensionId distinguishes which one sent the message.

Sync Payloads

The payload field is a discriminated union on the type property. There are four payload variants:

ContextSyncPayload

Used for create, update, and delete operations. This is the most common payload type -- it carries the actual browser context data.

typescript
interface ContextSyncPayload {
  readonly type: "context";
  readonly operation: "create" | "update" | "delete";
  readonly context: BrowserContext; // Full context snapshot
  readonly delta?: Partial<BrowserContext>; // Incremental changes (for updates)
}

For create operations, context contains the full BrowserContext and delta is absent. For update operations, context contains the full current state while delta contains only the fields that changed since the last sync -- this lets the server apply incremental updates efficiently. For delete operations, the context identifies which context to remove (the id field is sufficient, but the full context is included for logging and audit purposes).

Example: Creating a new page context

json
{
  "id": "sync_001",
  "type": "create",
  "timestamp": "2024-01-15T14:32:18.000Z",
  "userId": "user_456",
  "extensionId": "ext_chrome_789",
  "payload": {
    "type": "context",
    "operation": "create",
    "context": {
      "id": "ctx_abc123",
      "type": "page",
      "tabId": "tab_def456",
      "userId": "user_456",
      "capturedAt": "2024-01-15T14:32:17.500Z",
      "privacyLevel": "private",
      "tabMetadata": {
        "id": "tab_def456",
        "url": "https://docs.example.com/api",
        "title": "API Documentation",
        "isActive": true,
        "isLoading": false,
        "windowId": 1,
        "lastUpdated": "2024-01-15T14:32:17.000Z"
      },
      "pageMetadata": {
        "title": "API Documentation",
        "description": "Complete API reference",
        "language": "en"
      }
    }
  }
}

TabActivationPayload

Used for activate operations. Sent when the user switches to a different tab.

typescript
interface TabActivationPayload {
  readonly type: "tab_activation";
  readonly tabId: TabId; // Tab that gained focus
  readonly previousTabId?: TabId; // Tab that lost focus
  readonly windowId: number; // Browser window
  readonly url: string; // URL of activated tab
  readonly title?: string; // Title of activated tab
}

Tab activations are important for agents that track the user's current focus. When an agent receives a tab activation, it knows which context is currently "active" and can prioritize that context in its responses.

NavigationPayload

Used for navigate operations. Sent when the URL changes within a tab (as opposed to switching between tabs).

typescript
interface NavigationPayload {
  readonly type: "navigation";
  readonly tabId: TabId;
  readonly url: string;
  readonly previousUrl?: string;
  readonly title?: string;
  readonly transitionType?:
    | "link" // Clicked a link
    | "typed" // Typed URL in address bar
    | "auto_bookmark" // Opened from bookmarks
    | "auto_subframe" // Subframe navigation (iframe)
    | "manual_subframe" // Manual subframe navigation
    | "generated" // Generated by browser (new tab page)
    | "start_page" // Browser start/home page
    | "form_submit" // Form submission triggered navigation
    | "reload" // Page reload
    | "keyword" // Keyword search in address bar
    | "keyword_generated"; // Keyword search with generated URL
  readonly navigationTime: Date;
}

The 11 transitionType values come from the Chrome WebNavigation API and provide fine-grained understanding of how the user arrived at the current URL. This is distinct from the event system's navigationType -- the sync protocol captures the Chrome-specific transition types, while the event system normalizes them into a simpler set.

HeartbeatPayload

Used for heartbeat operations. Sent periodically to indicate the extension is alive and connected.

typescript
interface HeartbeatPayload {
  readonly type: "heartbeat";
  readonly activeTabId?: TabId; // Currently focused tab (if any)
  readonly totalTabs: number; // How many tabs are open
  readonly extensionVersion: string; // Extension build version
  readonly browserInfo: {
    readonly name: string; // "Chrome", "Firefox", etc.
    readonly version: string; // "120.0.6099.109"
    readonly platform: string; // "macOS", "Windows", etc.
  };
}

Heartbeats serve multiple purposes beyond liveness detection. The totalTabs count helps the service understand the user's workload. The extensionVersion enables the service to detect outdated extensions. The browserInfo provides device context even during idle periods.

SyncResponse

Every SyncMessage gets a SyncResponse:

typescript
interface SyncResponse {
  readonly messageId: string; // Echoes the SyncMessage.id
  readonly success: boolean;
  readonly timestamp: Date; // Server processing time
  readonly result?: SyncResult; // Success details (discriminated union)
  readonly error?: SyncError; // Failure details
}

SyncResult Variants

The result field is a discriminated union on type:

ContextSyncResult -- Confirms context was created, updated, or deleted:

typescript
{ type: "context", contextId: ContextId, operation: "create" | "update" | "delete", serverTimestamp: Date }

TabActivationResult -- Confirms tab activation was recorded:

typescript
{ type: "tab_activation", tabId: TabId, acknowledged: boolean }

NavigationResult -- Confirms navigation was recorded, optionally with a new context ID:

typescript
{ type: "navigation", tabId: TabId, url: string, contextId?: ContextId }

HeartbeatResult -- Returns server time and the interval for the next heartbeat:

typescript
{ type: "heartbeat", serverTime: Date, nextHeartbeatInterval: number }

The nextHeartbeatInterval in the heartbeat result is important. The server can dynamically adjust heartbeat frequency based on load. If the server is under pressure, it can request less frequent heartbeats by returning a larger interval.

SyncError

When a message fails, the response includes a SyncError:

typescript
interface SyncError {
  readonly type: SyncErrorType;
  readonly message: string;
  readonly code?: string;
  readonly details?: Record<string, unknown>;
  readonly retryAfter?: number; // Seconds to wait before retry
}

The 8 error types:

Error TypeMeaningRetryable?
authentication_requiredNo valid auth token providedNo (re-authenticate)
invalid_payloadMessage failed Zod validationNo (fix the payload)
context_not_foundUpdate/delete for nonexistent contextNo
tab_not_foundTab reference does not exist on serverNo
rate_limit_exceededToo many messages too quicklyYes (use retryAfter)
server_errorInternal server failureYes (with backoff)
network_errorNetwork-level failureYes (with backoff)
privacy_violationContext blocked by privacy rulesNo

The retryAfter field, when present, tells the extension how many seconds to wait before retrying. This is primarily used for rate_limit_exceeded errors.

Batching

For efficiency, multiple sync messages can be combined into a single request:

typescript
interface BatchSyncMessage {
  readonly id: string;
  readonly userId: UserId;
  readonly extensionId: string;
  readonly timestamp: Date;
  readonly messages: readonly SyncMessage[];
}

interface BatchSyncResponse {
  readonly messageId: string;
  readonly timestamp: Date;
  readonly results: readonly SyncResponse[];
  readonly partialSuccess: boolean; // true if some messages succeeded and some failed
}

The partialSuccess field is critical. A batch might contain 10 messages where 8 succeed and 2 fail (perhaps due to validation errors). The extension needs to know which messages failed so it can correct and retry only those messages, rather than resending the entire batch.

Sync Configuration

The SyncConfiguration type defines the extension's sync behavior:

typescript
interface SyncConfiguration {
  readonly heartbeatInterval: number; // Milliseconds between heartbeats
  readonly batchSize: number; // Max messages per batch
  readonly batchTimeout: number; // Max ms to wait before sending a partial batch
  readonly retryAttempts: number; // How many times to retry a failed message
  readonly retryDelay: number; // Base delay between retries (ms)
  readonly enabledContextTypes: readonly string[]; // Which context types to sync
  readonly privacySettings: {
    readonly defaultPrivacyLevel: string;
    readonly contentFiltering: boolean;
    readonly allowSensitiveData: boolean;
  };
}

Typical defaults:

  • heartbeatInterval: 30000 (30 seconds)
  • batchSize: 10
  • batchTimeout: 1000 (1 second -- send batch after 1 second even if not full)
  • retryAttempts: 3
  • retryDelay: 1000 (1 second, with exponential backoff)

The enabledContextTypes array lets users control which types of context are synced. A user who only wants page and selection contexts can disable form, media, and navigation contexts to reduce bandwidth.

Connection State Machine

The extension maintains a connection state that follows this state machine:

Loading diagram…
StateDescription
disconnectedNot connected. Initial state, or after max retries exceeded.
connectingEstablishing initial connection.
connectedActive connection. Messages can be sent.
reconnectingConnection lost, attempting to re-establish. Queued messages are buffered.
errorUnrecoverable error. Will transition to disconnected or reconnecting.

The ConnectionStatus type tracks the full state:

typescript
interface ConnectionStatus {
  readonly state: ConnectionState;
  readonly lastConnected?: Date; // When connection was last established
  readonly lastHeartbeat?: Date; // When last heartbeat was sent
  readonly errorMessage?: string; // Current error (if in error state)
  readonly retryCount: number; // How many reconnection attempts
  readonly queuedMessages: number; // Messages waiting to be sent
}

The queuedMessages count is important for UX. When the extension is in reconnecting state, it continues to buffer context changes locally. The UI can show the user that context is being captured but not yet synced, and how many messages are waiting.

Message Validation

All sync messages are validated against Zod schemas on the service side. The SyncMessageSchema validates the complete message structure including the discriminated union payload:

typescript
import {
  SyncMessageSchema,
  BatchSyncMessageSchema,
} from "@lovelace-ai/periscope-core";

// Single message validation
const result = SyncMessageSchema.safeParse(incomingMessage);

// Batch validation
const batchResult = BatchSyncMessageSchema.safeParse(incomingBatch);

Validation failures result in an invalid_payload error in the SyncResponse. The error's details field will contain the Zod validation error for debugging.

Next Steps