Skip to main content

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:

bash
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:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}

Create the source directory:

bash
mkdir src
touch src/chatbot.ts

Set up environment variables. Create a .env file (do not commit this):

bash
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

bash
# 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:

typescript
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

  1. Start the chatbot: npx tsx src/chatbot.ts
  2. Open your browser and navigate to a documentation page
  3. In the terminal, type: "What is this page about?"
  4. Navigate to a different page
  5. Type: "Summarize the key differences between this page and the last one"
  6. Select some text on the page
  7. 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