Tutorial: Build a Context-Aware Chatbot
In this tutorial, you will build a chatbot that knows what you are browsing. The chatbot subscribes to Periscope's real-time context stream, injects the current page into its prompt, and responds to your questions with awareness of what you are looking at.
By the end, you will have a working CLI chatbot that updates its understanding as you navigate between pages.
What You Will Build
A terminal-based chatbot that:
- Connects to Periscope's WebSocket and subscribes to
current-context - Maintains a live reference to the user's current page URL, title, and content
- Injects that context into the LLM system prompt
- Updates the context when the user navigates to a new page
- Respects privacy levels by skipping restricted pages
- Answers questions about the current page
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
Create a new project and install dependencies:
mkdir periscope-chatbot
cd periscope-chatbot
pnpm init
pnpm add @lovelace-ai/periscope-client @anthropic-ai/sdk
pnpm add -D typescript @types/node tsx
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
Create the source directory:
mkdir src
touch src/chatbot.ts
Set up environment variables. Create a .env file (do not commit this):
LOVELACE_API_TOKEN=your_lovelace_api_token_here
ANTHROPIC_API_KEY=your_anthropic_api_key_here
Step 2: Define the Context Type
Open src/chatbot.ts and define the types for browser context:
import Anthropic from "@anthropic-ai/sdk";
import { PeriscopeClient } from "@lovelace-ai/periscope-client";
import * as readline from "node:readline/promises";
interface BrowserContext {
url: string;
title: string;
content?: string;
selection?: string;
kind: string;
timestamp: string;
privacyLevel: "public" | "filtered" | "private" | "restricted";
device: {
id: string;
name: string;
};
}
let currentContext: BrowserContext | null = null;
The currentContext variable holds the latest context event. It gets replaced each time the user navigates to a new page.
Step 3: Connect to Periscope
Add the Periscope client connection with error handling:
async function connectToPeriscope(): Promise<PeriscopeClient> {
const token = process.env.LOVELACE_API_TOKEN;
if (!token) {
throw new Error(
"LOVELACE_API_TOKEN environment variable is required. " +
"Get one at https://developers.uselovelace.com/api-keys",
);
}
const client = new PeriscopeClient({
baseUrl: "https://periscope.uselovelace.com",
wsUrl: "wss://periscope.uselovelace.com/ws",
token,
});
client.on("disconnected", () => {
console.log("\n[Periscope disconnected -- context may be stale]");
});
client.on("reconnected", () => {
console.log("\n[Periscope reconnected]");
});
await client.connect();
console.log("[Connected to Periscope]");
return client;
}
Step 4: Subscribe to Current Context
Subscribe to the current-context channel. This fires whenever the user navigates to a new page, switches tabs, or selects text:
function subscribeToContext(client: PeriscopeClient): void {
client.subscribe("current-context", (context: BrowserContext) => {
// Skip restricted pages entirely
if (context.privacyLevel === "restricted") {
console.log("\n[Page is restricted -- context cleared]");
currentContext = null;
return;
}
// For private pages, only keep the URL
if (context.privacyLevel === "private") {
currentContext = {
...context,
content: undefined,
title: "(private)",
selection: undefined,
};
console.log(`\n[Navigated to private page: ${context.url}]`);
return;
}
// For filtered pages, keep URL and title but no content
if (context.privacyLevel === "filtered") {
currentContext = {
...context,
content: undefined,
selection: undefined,
};
console.log(`\n[Context updated: ${context.title} (filtered)]`);
return;
}
// Public pages get full context
currentContext = context;
console.log(`\n[Context updated: ${context.title}]`);
});
}
Notice how each privacy level strips different fields. This ensures your chatbot never sees content the user has marked as private.
Step 5: Build the Prompt Template
Create a function that builds the system prompt with current browser context. The prompt tells the LLM what the user is looking at and instructs it to reference that context in its answers:
function buildSystemPrompt(context: BrowserContext | null): string {
const baseInstructions = `You are a helpful assistant that is aware of what the user is currently viewing in their browser. Use this context to provide relevant, specific answers. When the user asks about something on the page, reference the actual content. If they ask about something unrelated to the page, still answer helpfully.`;
if (!context) {
return `${baseInstructions}\n\nThe user is not currently viewing any page, or their current page is restricted.`;
}
// Check if context is stale (older than 5 minutes)
const ageMs = Date.now() - new Date(context.timestamp).getTime();
if (ageMs > 5 * 60 * 1000) {
return `${baseInstructions}\n\nThe user's browser context is stale (last updated ${Math.round(ageMs / 60000)} minutes ago). The last known page was: ${context.url}`;
}
let prompt = `${baseInstructions}\n\n## Current Browser Context\n\n- **URL:** ${context.url}\n- **Page Title:** ${context.title}`;
if (context.content) {
const truncated = truncateContent(context.content, 6000);
prompt += `\n\n### Page Content\n\n${truncated}`;
}
if (context.selection) {
prompt += `\n\n### User's Selected Text\n\nThe user has selected this text on the page:\n> ${context.selection}\n\nThis selection likely indicates what they are interested in or want to ask about.`;
}
return prompt;
}
function truncateContent(content: string, maxChars: number): string {
if (content.length <= maxChars) {
return content;
}
const truncated = content.slice(0, maxChars);
const lastBreak = truncated.lastIndexOf("\n\n");
const cutPoint = lastBreak > maxChars * 0.75 ? lastBreak : maxChars;
return `${truncated.slice(0, cutPoint)}\n\n[... ${content.length - cutPoint} characters truncated]`;
}
Step 6: Handle Navigation Updates
When the user switches pages, the system prompt changes. The conversation history stays the same, but the LLM now sees different page content. Add a notification to the conversation so the LLM knows the context changed:
function handleNavigationUpdate(
previousUrl: string | null,
newContext: BrowserContext,
history: Array<{ role: "user" | "assistant"; content: string }>,
): void {
if (previousUrl && previousUrl !== newContext.url) {
// Insert a system-level note into the conversation
history.push({
role: "user",
content: `[The user navigated from ${previousUrl} to ${newContext.url} -- "${newContext.title}"]`,
});
history.push({
role: "assistant",
content: `Got it, I can see you're now on "${newContext.title}". How can I help with this page?`,
});
}
}
Step 7: Create the Chat Loop
Wire everything together into an interactive chat loop:
async function main(): Promise<void> {
// Connect to Periscope
const periscope = await connectToPeriscope();
subscribeToContext(periscope);
// Initialize Anthropic client
const anthropic = new Anthropic();
// Conversation state
const conversationHistory: Array<{
role: "user" | "assistant";
content: string;
}> = [];
let previousUrl: string | null = null;
// Set up readline for interactive input
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("\nContext-Aware Chatbot");
console.log("====================");
console.log("Browse the web and ask me questions about what you see.");
console.log('Type "quit" to exit.\n');
// Fetch initial context via REST
try {
const initialContext = await periscope.getCurrentContext();
if (initialContext) {
currentContext = initialContext;
console.log(`[Current page: ${initialContext.title}]\n`);
}
} catch {
console.log("[No initial context available -- start browsing!]\n");
}
// Chat loop
while (true) {
const userInput = await rl.question("You: ");
if (userInput.trim().toLowerCase() === "quit") {
break;
}
if (!userInput.trim()) {
continue;
}
// Handle navigation since last message
if (currentContext && currentContext.url !== previousUrl) {
handleNavigationUpdate(previousUrl, currentContext, conversationHistory);
previousUrl = currentContext.url;
}
// Add user message
conversationHistory.push({ role: "user", content: userInput });
// Build prompt with current context
const systemPrompt = buildSystemPrompt(currentContext);
// Call the LLM
try {
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
system: systemPrompt,
messages: conversationHistory,
});
const assistantText =
response.content[0].type === "text" ? response.content[0].text : "";
conversationHistory.push({ role: "assistant", content: assistantText });
console.log(`\nAssistant: ${assistantText}\n`);
} catch (error) {
const apiError = error as { status?: number; message?: string };
if (apiError.status === 429) {
console.log("\n[Rate limited -- wait a moment and try again]\n");
} else {
console.log(`\n[Error: ${apiError.message}]\n`);
}
}
}
// Clean up
periscope.disconnect();
rl.close();
console.log("Goodbye!");
}
main().catch(console.error);
Step 8: Run the Chatbot
Load your environment variables and run:
# Load env vars
export $(cat .env | xargs)
# Run the chatbot
npx tsx src/chatbot.ts
Complete Code Listing
Here is the full src/chatbot.ts file:
import Anthropic from "@anthropic-ai/sdk";
import { PeriscopeClient } from "@lovelace-ai/periscope-client";
import * as readline from "node:readline/promises";
// --- Types ---
interface BrowserContext {
url: string;
title: string;
content?: string;
selection?: string;
kind: string;
timestamp: string;
privacyLevel: "public" | "filtered" | "private" | "restricted";
device: { id: string; name: string };
}
type Message = { role: "user" | "assistant"; content: string };
// --- State ---
let currentContext: BrowserContext | null = null;
// --- Periscope Connection ---
async function connectToPeriscope(): Promise<PeriscopeClient> {
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,
});
client.on("disconnected", () => console.log("\n[Periscope disconnected]"));
client.on("reconnected", () => console.log("\n[Periscope reconnected]"));
await client.connect();
console.log("[Connected to Periscope]");
return client;
}
function subscribeToContext(client: PeriscopeClient): void {
client.subscribe("current-context", (ctx: BrowserContext) => {
if (ctx.privacyLevel === "restricted") {
currentContext = null;
return;
}
if (ctx.privacyLevel === "private") {
currentContext = {
...ctx,
content: undefined,
title: "(private)",
selection: undefined,
};
return;
}
if (ctx.privacyLevel === "filtered") {
currentContext = { ...ctx, content: undefined, selection: undefined };
console.log(`\n[Context: ${ctx.title} (filtered)]`);
return;
}
currentContext = ctx;
console.log(`\n[Context: ${ctx.title}]`);
});
}
// --- Prompt Building ---
function truncateContent(content: string, maxChars: number): string {
if (content.length <= maxChars) return content;
const cut = content.slice(0, maxChars);
const br = cut.lastIndexOf("\n\n");
const pos = br > maxChars * 0.75 ? br : maxChars;
return `${cut.slice(0, pos)}\n\n[...${content.length - pos} chars truncated]`;
}
function buildSystemPrompt(context: BrowserContext | null): string {
const base =
"You are a helpful assistant aware of the user's current browser page. Reference page content when relevant.";
if (!context) return `${base}\n\nNo browser context available.`;
const ageMs = Date.now() - new Date(context.timestamp).getTime();
if (ageMs > 300_000) {
return `${base}\n\nContext stale (${Math.round(ageMs / 60000)}m old). Last page: ${context.url}`;
}
let prompt = `${base}\n\n## Current Page\n- URL: ${context.url}\n- Title: ${context.title}`;
if (context.content) {
prompt += `\n\n### Content\n${truncateContent(context.content, 6000)}`;
}
if (context.selection) {
prompt += `\n\n### Selected Text\n> ${context.selection}`;
}
return prompt;
}
// --- Chat Loop ---
async function main(): Promise<void> {
const periscope = await connectToPeriscope();
subscribeToContext(periscope);
const anthropic = new Anthropic();
const history: Message[] = [];
let prevUrl: string | null = null;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("\nContext-Aware Chatbot\n====================");
console.log(
'Ask questions about what you\'re browsing. Type "quit" to exit.\n',
);
try {
const init = await periscope.getCurrentContext();
if (init) {
currentContext = init;
console.log(`[Current page: ${init.title}]\n`);
}
} catch {
console.log("[Start browsing to provide context]\n");
}
while (true) {
const input = await rl.question("You: ");
if (input.trim().toLowerCase() === "quit") break;
if (!input.trim()) continue;
if (currentContext && currentContext.url !== prevUrl) {
if (prevUrl) {
history.push({
role: "user",
content: `[Navigated to ${currentContext.url}]`,
});
history.push({
role: "assistant",
content: `I see you're now on "${currentContext.title}". How can I help?`,
});
}
prevUrl = currentContext.url;
}
history.push({ role: "user", content: input });
try {
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
system: buildSystemPrompt(currentContext),
messages: history,
});
const text =
response.content[0].type === "text" ? response.content[0].text : "";
history.push({ role: "assistant", content: text });
console.log(`\nAssistant: ${text}\n`);
} catch (error) {
const err = error as { status?: number; message?: string };
if (err.status === 429) {
console.log("\n[Rate limited -- wait and retry]\n");
} else {
console.log(`\n[Error: ${err.message}]\n`);
}
}
}
periscope.disconnect();
rl.close();
console.log("Goodbye!");
}
main().catch(console.error);
Try It Out
- Start the chatbot:
npx tsx src/chatbot.ts - Open your browser and navigate to a documentation page
- In the terminal, type: "What is this page about?"
- Navigate to a different page
- Type: "Summarize the key differences between this page and the last one"
- Select some text on the page
- Type: "Explain the text I just selected"
You should see [Context updated: ...] messages as you navigate, and the chatbot's answers should reference specific content from the current page.
Next Steps
- Add the Research Assistant pattern to track browsing sessions over time
- Read the Agent Integration Guide for advanced prompt engineering with browser context
- Explore the WebSocket API reference for all available channels and event types