Periscope WebSocket API
Real-time browser context streaming via channel subscriptions
The WebSocket API enables agents to receive browser context updates in real time as users browse. Instead of polling the REST API, agents subscribe to channels and receive push notifications whenever context changes.
For conceptual background on the streaming architecture, see Real-Time Streaming. For a higher-level client that handles connection management automatically, see the SDKs.
Connection
| Environment | URL |
|---|---|
| Development | ws://localhost:4501 |
| Production | wss://periscope.uselovelace.com/ws |
Connection Lifecycle
Every WebSocket session follows this sequence:
The server checks for heartbeats every 30 seconds. If a client hasn't sent a heartbeat within 60 seconds, the server terminates the connection. See Heartbeat for details.
Message Envelope
All messages are JSON objects with a type field. Client-to-server messages may include an optional id for request/response correlation. Server responses include success: boolean.
// Client → Server
{ "type": "auth", "id": "msg_1", "data": { "token": "...", "userId": "..." } }
// Server → Client
{ "type": "auth_response", "id": "msg_1", "success": true, "data": { ... } }
| Field | Type | Description |
|---|---|---|
type | string | Message type identifier (see sections below) |
id | string? | Optional correlation ID for request/response matching |
success | boolean | Present on server responses — whether the operation succeeded |
data | object? | Message-specific payload |
error | string? | Error description when success is false |
Authentication
Authentication is required before subscribing to any channels. Send an auth message within 10 seconds of connecting, or the server will close the connection.
Client → Server:
{
"type": "auth",
"id": "msg_1",
"data": {
"token": "YOUR_API_KEY",
"userId": "user_456"
}
}
Server → Client (success):
{
"type": "auth_response",
"id": "msg_1",
"success": true,
"data": {
"connectionId": "550e8400-e29b-41d4-a716-446655440000"
}
}
Server → Client (failure):
{
"type": "auth_response",
"id": "msg_1",
"success": false,
"error": "Invalid token or userId"
}
Both token and userId must be non-empty strings. The token is validated against the same API keys used for the REST API.
Channels
Subscribe to channels to receive specific types of context updates. Each channel delivers different events at different frequencies.
user-contexts
All context changes for the authenticated user. This is the most comprehensive channel — it fires whenever any context is created, updated, or deleted, regardless of type.
| Fires when | Typical frequency |
|---|---|
| Any context is synced, updated, or deleted | 1-5 events/minute during active browsing |
Best for: Building agents that need complete awareness of all browsing activity.
current-context
Updates to the user's current active context only. Fires when the user navigates to a new page or switches to a different tab. This is the most commonly used channel for agent integrations.
| Fires when | Typical frequency |
|---|---|
| User navigates to a new page or switches tabs | Every page navigation |
Best for: Agents that need to know what the user is currently looking at without tracking full history.
tab-events
Tab lifecycle events. Fires when tabs are opened, closed, activated, or updated. Useful for understanding multitasking patterns and tracking focus.
| Fires when | Typical frequency |
|---|---|
| Tab opened, closed, activated, or title/URL updated | Several events/minute during multitasking |
Event payload includes:
{
"type": "event",
"success": true,
"data": {
"type": "tab_activated",
"userId": "user_456",
"tabId": "tab_123",
"url": "https://docs.example.com",
"title": "Example Docs",
"timestamp": "2026-03-02T12:00:00Z"
}
}
global-events
System-wide events. Fires for service maintenance notifications, configuration changes, and broadcast messages. Subscribe to this channel to handle service disruptions gracefully.
| Fires when | Typical frequency |
|---|---|
| Maintenance windows, config updates, system broadcasts | Rare (hours to days between events) |
Subscribe / Unsubscribe
Subscribe
Requires authentication. Subscribing to an already-subscribed channel is a no-op (returns success).
Client → Server:
{
"type": "subscribe",
"id": "msg_2",
"data": { "channel": "current-context" }
}
Server → Client (success):
{
"type": "subscribe_response",
"id": "msg_2",
"success": true,
"data": { "channel": "current-context" }
}
Server → Client (failure — not authenticated):
{
"type": "subscribe_response",
"id": "msg_2",
"success": false,
"error": "Authentication required before subscribing"
}
Unsubscribe
Client → Server:
{
"type": "unsubscribe",
"id": "msg_3",
"data": { "channel": "current-context" }
}
Server → Client:
{
"type": "unsubscribe_response",
"id": "msg_3",
"success": true,
"data": { "channel": "current-context" }
}
Heartbeat
The server checks connections every 30 seconds and terminates any connection that has not sent a heartbeat within 60 seconds. Clients should send heartbeats every 25–30 seconds.
Client → Server:
{
"type": "heartbeat",
"id": "msg_4"
}
Server → Client:
{
"type": "heartbeat_response",
"id": "msg_4",
"success": true,
"data": { "timestamp": "2026-03-02T12:00:30.000Z" }
}
The SDK handles heartbeats automatically. If you're using raw WebSocket, set up a 25-second interval:
const heartbeatInterval = setInterval(() => {
ws.send(JSON.stringify({ type: "heartbeat" }));
}, 25_000);
ws.onclose = () => clearInterval(heartbeatInterval);
Event Messages
Events are pushed to subscribed clients when context changes occur. The data field contains the event payload — its structure depends on the channel and event type.
{
"type": "event",
"success": true,
"data": {
"type": "context_updated",
"userId": "user_456",
"timestamp": "2026-03-02T12:31:00.000Z",
"payload": {
"contextId": "ctx_abc123",
"url": "https://docs.example.com/new-page",
"title": "New Page Title",
"type": "page",
"privacyLevel": "public"
}
}
}
For the full list of event payload types, see Event Types Reference.
Connection Management
Multiple connections per user: A single user can have multiple active WebSocket connections (e.g., agent running on a server + another in a CLI tool). Each connection has a unique connectionId. Events are broadcast to all connections for that user.
Connection cleanup: When a connection closes (gracefully or due to error), the server automatically removes it from all channel subscriptions and user tracking. No explicit cleanup is required.
Reconnection Strategy
The server does not persist connection state. On reconnection, you must re-authenticate and re-subscribe to all channels.
Recommended approach:
- Establish a new WebSocket connection
- Re-authenticate with
authmessage - Re-subscribe to previously subscribed channels (track them client-side)
- Resume consuming events
Exponential backoff:
| Attempt | Wait time |
|---|---|
| 1 | 1 second |
| 2 | 2 seconds |
| 3 | 4 seconds |
| 4 | 8 seconds |
| 5+ | 30 seconds (cap) |
Add random jitter (±500ms) to prevent thundering herd when many clients reconnect simultaneously.
The TypeScript SDK handles reconnection, re-authentication, and re-subscription automatically.
Error Messages
Sent for unknown message types, malformed JSON, or protocol violations.
{
"type": "error",
"success": false,
"error": "Unknown message type: foo"
}
| Scenario | Server behavior |
|---|---|
| Invalid JSON | Returns error message, increments error count |
| Unknown message type | Returns error message with the type name |
| Subscribe without auth | Returns subscribe_response with success: false |
| Invalid channel name | Returns subscribe_response with success: false |
| Connection timeout (60s) | Server terminates connection |
| Send failure | Logged server-side, connection cleaned up |
Complete Example
Full connection lifecycle using raw WebSocket:
const ws = new WebSocket("wss://periscope.uselovelace.com/ws");
// Track subscriptions for reconnection
const channels = ["current-context", "tab-events"];
ws.onopen = () => {
// Step 1: Authenticate
ws.send(
JSON.stringify({
type: "auth",
id: "auth_1",
data: { token: "YOUR_API_KEY", userId: "user_456" },
}),
);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "welcome":
console.log("Connected:", msg.data.connectionId);
break;
case "auth_response":
if (msg.success) {
// Step 2: Subscribe to channels
for (const channel of channels) {
ws.send(
JSON.stringify({
type: "subscribe",
data: { channel },
}),
);
}
} else {
console.error("Auth failed:", msg.error);
}
break;
case "heartbeat_response":
// Heartbeat acknowledged
break;
case "event":
// Step 3: Handle events
console.log(`[${msg.data.type}]`, msg.data.payload);
break;
case "error":
console.error("Server error:", msg.error);
break;
}
};
// Step 4: Send heartbeats
const heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "heartbeat" }));
}
}, 25_000);
ws.onclose = () => {
clearInterval(heartbeat);
// Implement reconnection with exponential backoff
};
For a simpler approach, use the TypeScript SDK which handles all of this automatically.
Next Steps
- Real-Time Streaming — Conceptual guide to the streaming architecture
- SDKs — Client libraries that wrap this protocol
- REST API — For polling and historical queries
- Agent Integration — How agents consume context in practice