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:
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:
| Operation | When Used |
|---|---|
create | New context captured (page load, new selection, form interaction) |
update | Existing context changed (page content updated, form field changed) |
delete | Context no longer valid (tab closed, page navigated away) |
activate | Tab gained focus |
deactivate | Tab lost focus |
navigate | User navigated within a tab |
heartbeat | Periodic 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.
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
{
"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.
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).
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.
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:
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:
{ type: "context", contextId: ContextId, operation: "create" | "update" | "delete", serverTimestamp: Date }
TabActivationResult -- Confirms tab activation was recorded:
{ type: "tab_activation", tabId: TabId, acknowledged: boolean }
NavigationResult -- Confirms navigation was recorded, optionally with a new context ID:
{ type: "navigation", tabId: TabId, url: string, contextId?: ContextId }
HeartbeatResult -- Returns server time and the interval for the next heartbeat:
{ 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:
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 Type | Meaning | Retryable? |
|---|---|---|
authentication_required | No valid auth token provided | No (re-authenticate) |
invalid_payload | Message failed Zod validation | No (fix the payload) |
context_not_found | Update/delete for nonexistent context | No |
tab_not_found | Tab reference does not exist on server | No |
rate_limit_exceeded | Too many messages too quickly | Yes (use retryAfter) |
server_error | Internal server failure | Yes (with backoff) |
network_error | Network-level failure | Yes (with backoff) |
privacy_violation | Context blocked by privacy rules | No |
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:
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:
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: 10batchTimeout: 1000 (1 second -- send batch after 1 second even if not full)retryAttempts: 3retryDelay: 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:
| State | Description |
|---|---|
disconnected | Not connected. Initial state, or after max retries exceeded. |
connecting | Establishing initial connection. |
connected | Active connection. Messages can be sent. |
reconnecting | Connection lost, attempting to re-establish. Queued messages are buffered. |
error | Unrecoverable error. Will transition to disconnected or reconnecting. |
The ConnectionStatus type tracks the full state:
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:
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
- Browser Context Model -- The data structure carried by context sync payloads
- Privacy Model -- How context is filtered before syncing
- Real-Time Streaming -- How synced contexts reach agents via WebSocket