Skip to main content

Real-Time Streaming

Concepts

Periscope provides real-time context streaming over WebSocket connections. Rather than polling the REST API for changes, agents and applications can subscribe to channels that deliver events as they happen -- typically within milliseconds of the browser activity that triggered them. This page covers the channel system, authentication handshake, subscription lifecycle, heartbeats, multi-device propagation, and reconnection strategy.

Channels

Periscope organizes real-time events into four channels. Each channel delivers a different slice of context data:

user-contexts

Delivers all context changes for the authenticated user. Every time a BrowserContext is created, updated, or deleted, a corresponding event appears on this channel. This is the most comprehensive channel -- if you only subscribe to one channel, this is the one.

Use this when your agent needs a complete picture of the user's browsing activity across all tabs and windows.

current-context

Delivers updates only for the user's currently active context. When the user switches tabs, this channel begins delivering events from the new active tab and stops delivering events from the previous one. This is a filtered view of user-contexts that always reflects "what is the user looking at right now?"

Use this when your agent only cares about the user's immediate focus. This significantly reduces message volume compared to user-contexts because background tab activity is excluded.

tab-events

Delivers tab lifecycle events: tab activated, tab navigated, tab closed. Does not deliver context content -- only structural changes to the tab state. This is useful for agents that need to track which tabs are open and which is active, without receiving full context data.

global-events

Delivers system-wide events: maintenance announcements, configuration changes, service status updates. These events are not user-specific and are broadcast to all connected clients regardless of authentication. This channel is low-volume and primarily used for operational coordination.

Connection Lifecycle

A WebSocket connection goes through a specific sequence of messages to become fully operational:

Loading diagram…

1. Connect

The client opens a WebSocket connection to the Periscope service. The server responds with a welcome message containing a unique connectionId. No authentication is required at this point.

2. Authenticate

The client sends an auth message with a bearer token and user ID. The server validates the token and associates the connection with the user. Until authentication succeeds, the only messages the server will accept are auth and heartbeat. Subscription and broadcast requests are rejected with an unauthorized error.

3. Subscribe

After authentication, the client sends a subscribe message specifying which channels to join. The server confirms with a subscription-confirmed message listing the channels that were successfully subscribed.

typescript
// Client sends:
{
  type: "subscribe",
  channels: ["user-contexts", "tab-events"],
  requestId: "req_001"
}

// Server responds:
{
  type: "subscription-confirmed",
  channels: ["user-contexts", "tab-events"],
  requestId: "req_001",
  subscribedAt: "2024-01-15T14:32:18.000Z"
}

4. Receive Events

Once subscribed, events flow automatically. Each event arrives as a state-update message:

typescript
{
  type: "state-update",
  channel: "user-contexts",
  event: {
    id: "evt_abc123",
    timestamp: "2024-01-15T14:32:19.000Z",
    userId: "user_456",
    channelId: "user:456",
    source: { /* ... */ },
    payload: {
      kind: "text.selection",
      data: { text: "selected text", range: { start: 0, end: 13 } }
    }
  },
  publishedAt: "2024-01-15T14:32:19.050Z",
  sequenceNumber: 42
}

The sequenceNumber is per-channel and monotonically increasing. If a client detects a gap in sequence numbers, it knows it missed events (likely during a reconnection) and can fetch the missing events via the REST API.

The difference between event.timestamp (when the event occurred in the browser) and publishedAt (when the server published it to the channel) represents the end-to-end latency.

5. Unsubscribe

The client can unsubscribe from specific channels at any time without closing the connection:

typescript
{
  type: "unsubscribe",
  channels: ["tab-events"],
  requestId: "req_002"
}

Heartbeats

Both the client and server participate in heartbeat exchange to detect stale connections:

Client heartbeat interval: 25-30 seconds. The client sends a heartbeat message at this interval to indicate it is still alive.

Server heartbeat check: Every 30 seconds. The server checks when it last received a message (any message, not just heartbeats) from each connection.

Server timeout: 60 seconds. If the server has not received any message from a connection in 60 seconds, it considers the connection dead and cleans it up -- removing it from all channels and freeing resources.

The asymmetry (client sends every 25-30 seconds, server times out at 60 seconds) provides tolerance for network jitter and temporary delays without false positives.

Multi-Device Event Propagation

A single user might have Periscope running on multiple devices -- Chrome on a laptop, Firefox on a desktop, a mobile browser on a phone. Each device maintains its own WebSocket connection to the service.

When the user performs an action on one device, the resulting event is delivered to all of that user's connected WebSocket sessions via broadcastToUser. This means an agent running on the laptop can see events from the desktop browser in real time.

Loading diagram…

Channel broadcasting (broadcastToChannel) works differently -- it delivers to all subscribers of a channel regardless of user. This is used for global-events and for scenarios where multiple users share a channel (team contexts, collaborative sessions).

Connection Cleanup

When a WebSocket connection closes -- whether the client disconnected gracefully, the network dropped, or the server detected a timeout -- the server automatically:

  1. Removes the connection from all subscribed channels
  2. Updates channel subscriber counts
  3. Frees connection resources (authentication state, subscription tracking)
  4. Logs the disconnection with connection metadata

This cleanup is idempotent. If a connection is already removed (for example, the cleanup fires on both a close event and a timeout), the second cleanup is a no-op.

REST vs. WebSocket Decision Guide

Not everything needs to be real-time. Here is when to use each:

Use CaseRecommendedWhy
Agent needs current contextWebSocket (current-context)Lowest latency, no polling overhead
Agent reacts to user actionsWebSocket (user-contexts)Events arrive as they happen
Query historical contextsREST APITime-range queries, pagination, search
One-time context fetchREST APISingle request, no connection overhead
Background analyticsREST APIBatch processing, no real-time need
Dashboard showing live activityWebSocket (tab-events)Immediate tab state updates
System health monitoringWebSocket (global-events)Maintenance and status updates
Building a context search UIREST APIComplex filtering, full-text search

As a general rule: if you need to know about changes as they happen, use WebSocket. If you need to query data that already exists, use REST.

Reconnection Strategy

Network connections fail. Periscope clients should implement a reconnection strategy with exponential backoff:

Attempt 1: wait 1 second
Attempt 2: wait 2 seconds
Attempt 3: wait 4 seconds
Attempt 4: wait 8 seconds
Attempt 5: wait 16 seconds
Attempt 6: wait 30 seconds (cap)
Attempt 7+: wait 30 seconds (cap)

Add random jitter (0-500ms) to each delay to prevent thundering herd when many clients reconnect simultaneously after a service restart.

On reconnection:

  1. Open a new WebSocket connection
  2. Re-authenticate with the same token
  3. Re-subscribe to all previously subscribed channels (track these client-side)
  4. Check the last received sequenceNumber per channel
  5. Fetch any missed events via REST API using the sequence gap

The critical detail is step 3: the client must track its subscriptions locally. The server does not remember subscriptions across connections. When you reconnect, you get a fresh connection with no subscriptions. Your client code must maintain a set of desired subscriptions and re-establish them after each reconnection.

typescript
// Pseudocode for reconnection
class PeriscopeClient {
  private desiredChannels: Set<string> = new Set();
  private lastSequence: Map<string, number> = new Map();

  subscribe(channel: string) {
    this.desiredChannels.add(channel);
    this.sendSubscribe([channel]);
  }

  private async onReconnect() {
    await this.authenticate(this.token);
    // Re-subscribe to all channels
    if (this.desiredChannels.size > 0) {
      this.sendSubscribe([...this.desiredChannels]);
    }
    // Fetch missed events
    for (const [channel, seq] of this.lastSequence) {
      const missed = await this.fetchMissedEvents(channel, seq);
      this.processMissedEvents(missed);
    }
  }
}

Message Types Reference

Client-to-Server Messages

TypePurposeRequires Auth?
subscribeJoin channel(s)Yes
unsubscribeLeave channel(s)Yes
broadcastSend event to channel subscribersYes
heartbeatLiveness signalNo

Server-to-Client Messages

TypePurpose
state-updateDelivers an event from a subscribed channel
subscription-confirmedConfirms channel subscription
errorReports an error (invalid message, unauthorized, rate limited)

Error Codes

CodeMeaningRecovery
invalid_messageMessage failed validationFix message format
unauthorizedNot authenticated or token expiredRe-authenticate
channel_not_foundRequested channel does not existCheck channel name
rate_limitedToo many messagesBack off and retry

Next Steps