Tutorial: Build a Research Assistant
In this tutorial, you will build an agent that tracks your browsing across tabs, collects page summaries and text selections, and generates a summary of your research session on demand. The agent uses Periscope's WebSocket streaming for real-time tracking and the REST API for querying history.
What You Will Build
A research tracking agent that:
- Subscribes to
user-contextsandtab-eventschannels to capture all browsing activity - Accumulates visited pages with URLs, titles, timestamps, and time spent
- Marks
text.selectionevents as high-signal items the user found important - Builds a research session data structure from accumulated events
- Supports "what did I read about X?" queries using the REST search API
- Generates a session summary using an LLM
Prerequisites
- Node.js 20 or higher
- A Lovelace account with an API token
- The Periscope browser extension installed and connected
- An Anthropic API key
Step 1: Project Setup
mkdir research-assistant
cd research-assistant
pnpm init
pnpm add @lovelace-ai/periscope-client @anthropic-ai/sdk
pnpm add -D typescript @types/node tsx
mkdir src
touch src/research.ts
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
Set up environment variables in .env:
LOVELACE_API_TOKEN=your_lovelace_api_token
ANTHROPIC_API_KEY=your_anthropic_api_key
Step 2: Define the Session Data Structure
A research session accumulates page visits and text selections over time. Define the types:
import Anthropic from "@anthropic-ai/sdk";
import { PeriscopeClient } from "@lovelace-ai/periscope-client";
import * as readline from "node:readline/promises";
interface PageVisit {
url: string;
title: string;
firstVisited: Date;
lastVisited: Date;
visitCount: number;
/** Time spent on this page in milliseconds */
timeSpentMs: number;
}
interface TextSelection {
url: string;
pageTitle: string;
text: string;
timestamp: Date;
}
interface ResearchSession {
startedAt: Date;
pages: Map<string, PageVisit>;
selections: TextSelection[];
lastActiveUrl: string | null;
lastActiveAt: Date | null;
}
function createSession(): ResearchSession {
return {
startedAt: new Date(),
pages: new Map(),
selections: [],
lastActiveUrl: null,
lastActiveAt: null,
};
}
The pages map is keyed by URL so we can track revisits. The selections array preserves every text selection in order -- these are the high-signal items.
Step 3: Subscribe to Browsing Events
Connect to Periscope and subscribe to both user-contexts (for page content and selections) and tab-events (for tab switches):
interface ContextEvent {
url: string;
title: string;
content?: string;
selection?: string;
kind: string;
timestamp: string;
privacyLevel: string;
}
interface TabEvent {
tabId: string;
action: string;
url?: string;
title?: string;
timestamp: string;
}
function trackBrowsing(
client: PeriscopeClient,
session: ResearchSession,
): void {
client.subscribe("user-contexts", (event: ContextEvent) => {
if (event.privacyLevel === "restricted") return;
const now = new Date(event.timestamp);
// Track time on previous page
if (session.lastActiveUrl && session.lastActiveAt) {
const prev = session.pages.get(session.lastActiveUrl);
if (prev) {
prev.timeSpentMs += now.getTime() - session.lastActiveAt.getTime();
prev.lastVisited = now;
}
}
if (event.kind === "page.navigation" || event.kind === "tab.activated") {
recordPageVisit(session, event.url, event.title, now);
session.lastActiveUrl = event.url;
session.lastActiveAt = now;
console.log(`\n[Page: ${event.title}]`);
}
if (event.kind === "text.selection" && event.selection) {
session.selections.push({
url: event.url,
pageTitle: event.title,
text: event.selection,
timestamp: now,
});
console.log(
`\n[Selection on "${event.title}": "${event.selection.slice(0, 60)}..."]`,
);
}
});
client.subscribe("tab-events", (event: TabEvent) => {
if (event.action === "activated" && event.url) {
const now = new Date(event.timestamp);
recordPageVisit(session, event.url, event.title ?? event.url, now);
session.lastActiveUrl = event.url;
session.lastActiveAt = now;
}
});
}
function recordPageVisit(
session: ResearchSession,
url: string,
title: string,
timestamp: Date,
): void {
const existing = session.pages.get(url);
if (existing) {
existing.visitCount += 1;
existing.lastVisited = timestamp;
} else {
session.pages.set(url, {
url,
title,
firstVisited: timestamp,
lastVisited: timestamp,
visitCount: 1,
timeSpentMs: 0,
});
}
}
Step 4: Mark Selections as Important
Text selections are high-signal signals -- the user actively highlighted something. The selections array is already being populated in Step 3. Add a helper to display them:
function getImportantFindings(session: ResearchSession): string {
if (session.selections.length === 0) {
return "No text selections recorded yet.";
}
return session.selections
.map(
(sel, i) =>
`${i + 1}. From "${sel.pageTitle}" (${sel.url}):\n > ${sel.text}`,
)
.join("\n\n");
}
Step 5: Query History with the REST API
Add a function that searches Periscope's history for pages matching a topic. This uses the GET /search endpoint:
interface SearchResult {
id: string;
url: string;
title: string;
content?: string;
timestamp: string;
}
interface SearchResponse {
items: SearchResult[];
total: number;
}
async function searchBrowsingHistory(
client: PeriscopeClient,
query: string,
since?: Date,
): Promise<SearchResult[]> {
const params = new URLSearchParams({ q: query, limit: "20" });
if (since) {
params.set("since", since.toISOString());
}
const response: SearchResponse = await client.rest(
`/search?${params.toString()}`,
);
return response.items;
}
This lets the user ask "what did I read about React hooks?" and get matching pages from their browsing history.
Step 6: Generate a Session Summary
Build a function that takes the accumulated session data and asks an LLM to summarize it:
async function generateSessionSummary(
anthropic: Anthropic,
session: ResearchSession,
): Promise<string> {
const pages = Array.from(session.pages.values())
.sort((a, b) => b.timeSpentMs - a.timeSpentMs)
.slice(0, 30);
const duration = Date.now() - session.startedAt.getTime();
const durationMinutes = Math.round(duration / 60_000);
const pageList = pages
.map(
(p) =>
`- ${p.title} (${p.url}) — visited ${p.visitCount}x, ~${Math.round(p.timeSpentMs / 1000)}s`,
)
.join("\n");
const selections = getImportantFindings(session);
const prompt = `Summarize this research session in 3-5 paragraphs. Identify the main topics, key findings, and any patterns in what the user explored.
## Session Info
- Duration: ${durationMinutes} minutes
- Pages visited: ${session.pages.size}
- Text selections: ${session.selections.length}
## Pages Visited (sorted by time spent)
${pageList}
## Text Selections (user-highlighted content)
${selections}
Write a natural summary like: "You spent about X minutes researching Y. The main focus was on... You highlighted several key points about..."`;
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: prompt }],
});
return response.content[0].type === "text" ? response.content[0].text : "";
}
Step 7: Build the Interactive Loop
Wire everything together with a command-based interface:
async function main(): Promise<void> {
const token = process.env.LOVELACE_API_TOKEN;
if (!token) throw new Error("LOVELACE_API_TOKEN is required");
const client = new PeriscopeClient({
baseUrl: "https://periscope.uselovelace.com",
wsUrl: "wss://periscope.uselovelace.com/ws",
token,
});
await client.connect();
console.log("[Connected to Periscope]");
const anthropic = new Anthropic();
const session = createSession();
trackBrowsing(client, session);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("\nResearch Assistant");
console.log("==================");
console.log("Commands:");
console.log(' "summary" — Generate a summary of your session');
console.log(' "selections" — Show all text selections');
console.log(' "pages" — List all visited pages');
console.log(' "search <topic>" — Search your browsing history');
console.log(' "quit" — Exit');
console.log("\nBrowse the web -- I'm tracking your session.\n");
while (true) {
const input = await rl.question("> ");
const trimmed = input.trim().toLowerCase();
if (trimmed === "quit") break;
if (trimmed === "summary") {
if (session.pages.size === 0) {
console.log("\nNo pages visited yet. Start browsing!\n");
continue;
}
console.log("\nGenerating summary...\n");
const summary = await generateSessionSummary(anthropic, session);
console.log(summary);
console.log();
continue;
}
if (trimmed === "selections") {
console.log("\n" + getImportantFindings(session) + "\n");
continue;
}
if (trimmed === "pages") {
const pages = Array.from(session.pages.values());
if (pages.length === 0) {
console.log("\nNo pages visited yet.\n");
continue;
}
console.log(`\n${pages.length} pages visited:`);
for (const page of pages) {
const seconds = Math.round(page.timeSpentMs / 1000);
console.log(
` ${page.title}\n ${page.url} (${page.visitCount} visits, ~${seconds}s)`,
);
}
console.log();
continue;
}
if (trimmed.startsWith("search ")) {
const query = input.trim().slice(7);
console.log(`\nSearching for "${query}"...\n`);
const results = await searchBrowsingHistory(
client,
query,
session.startedAt,
);
if (results.length === 0) {
console.log("No matching pages found.\n");
continue;
}
console.log(`Found ${results.length} matching pages:`);
for (const result of results) {
console.log(` ${result.title}\n ${result.url}`);
}
console.log();
continue;
}
// Treat anything else as a question about the session
if (trimmed.length > 0) {
const pages = Array.from(session.pages.values());
const pageContext = pages
.map((p) => `- ${p.title} (${p.url})`)
.join("\n");
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 512,
system: `You are a research assistant. The user has been browsing the following pages:\n${pageContext}\n\nTheir text selections:\n${getImportantFindings(session)}`,
messages: [{ role: "user", content: input }],
});
const text =
response.content[0].type === "text" ? response.content[0].text : "";
console.log(`\n${text}\n`);
}
}
client.disconnect();
rl.close();
console.log("Session ended.");
}
main().catch(console.error);
Step 8: Run It
export $(cat .env | xargs)
npx tsx src/research.ts
Complete Code Listing
The full src/research.ts is the combination of all steps above. Copy the code from Steps 2 through 7 into a single file, with the imports at the top and main() at the bottom.
Example Output
Here is what a session summary looks like after 20 minutes of browsing about WebSocket performance:
> summary
Generating summary...
You spent about 22 minutes researching WebSocket performance optimization. Your
session focused on three main areas:
First, you explored general WebSocket best practices on the MDN documentation
and a LogRocket blog post, spending about 6 minutes between the two. You
highlighted a section about binary frames being more efficient than text frames
for large payloads.
Second, you dove into specific library comparisons on GitHub, looking at ws,
uWebSockets.js, and Socket.IO benchmarks. The uWebSockets.js repository got the
most attention (4 minutes), and you selected a benchmark table showing 10x
throughput improvement over ws.
Third, you checked Stack Overflow for connection pooling strategies, visiting
three different answers. You highlighted a code snippet showing a connection
pool implementation with automatic reconnection.
Key takeaway from your selections: binary WebSocket frames and connection pooling
appear to be the two highest-impact optimizations for your use case.
Next Steps
- Combine this with the Context-Aware Chatbot for a chatbot that both knows your current page and remembers your research history
- Read the Multi-Device Sync Guide to track research across multiple browsers
- Explore the REST API reference for additional query parameters on the search and history endpoints