Skip to content

Recurate Annotator — Chrome Extension Architecture

Status: Built and working Date: March 4, 2026 Target: V1 — claude.ai, ChatGPT (chat.com), Copilot consumer (copilot.microsoft.com), Copilot enterprise (m365.cloud.microsoft/chat)

See also: VS Code Extension Architecture for the Claude Code terminal workflow variant.


Table of Contents

  1. Overview
  2. Tech Stack
  3. Project Structure
  4. Component Architecture
  5. State Management
  6. Messaging Architecture
  7. Platform Integration — claude.ai
  8. Platform Integration — ChatGPT
  9. Platform Integration — Copilot Consumer
  10. Platform Integration — Copilot Enterprise
  11. Annotation UX
  12. Structured Feedback Format
  13. Extensibility
  14. Known Risks & Mitigations

1. Overview

The Recurate Annotator is a Chrome extension that adds a side panel to AI chat interfaces. The side panel displays the AI's latest response and provides annotation tools — highlight (keep/carry forward), strikethrough (drop/discard), dig deeper (elaborate), and verify (fact-check). The extension generates structured feedback text and injects it into the platform's native text box alongside the user's next question.

V1 scope: claude.ai, ChatGPT (chat.com), Microsoft Copilot consumer (copilot.microsoft.com), and Microsoft Copilot enterprise (m365.cloud.microsoft/chat). Additional platforms (grok.com, gemini.google.com) follow the same architecture with platform-specific selectors.

Core loop:

  1. Content script detects AI response completion on the platform
  2. Content script extracts response HTML → sends to side panel via background service worker
  3. Side panel renders the response with annotation capabilities
  4. User annotates (highlight / strikethrough / dig deeper / verify) via floating toolbar
  5. Structured feedback auto-injects into the platform's text box (zero-click flow)
  6. User types their message after the feedback and sends normally

2. Tech Stack

Component Technology Why
Extension framework WXT File-based entrypoint discovery, auto-manifest generation, HMR, smallest bundle output. Actively maintained (unlike Plasmo).
Side panel UI Preact 4 KB runtime, React-compatible API, deep familiarity for rapid development. Lighter than React, more capable than vanilla JS for our state management needs.
State management Preact Signals Fine-grained reactivity — only the annotation that changes re-renders, not the entire panel. No external state library needed.
Language TypeScript Type safety across content scripts, background, and side panel. Catches messaging contract errors at compile time.
Build Vite (via WXT) Fast builds, HMR, handles JSX transform via @preact/preset-vite.
Content scripts Vanilla TypeScript Content scripts do DOM observation and text injection. No framework needed — they don't render UI.

Why not other frameworks?

  • Svelte 5: Better technical fit (smaller runtime, Runes), but less familiar — would slow development and increase bug risk.
  • Lit: Weaker state management, Shadow DOM complicates style inheritance for markdown rendering.
  • Solid.js: Steep learning curve, small extension ecosystem.
  • Vanilla JS: The side panel has enough interactive complexity (annotation state, floating toolbar, preview, undo) that we'd end up building a mini-framework.

Manifest V3 CSP compliance

Chrome's Content Security Policy bans eval() and new Function() on extension pages. Preact with pre-compiled JSX (via Vite build step) produces static JavaScript only — fully compliant. No runtime template compilation.


3. Project Structure

extensions/chrome/
├── package.json                       # Dependencies and scripts
├── tsconfig.json                      # TypeScript configuration
├── wxt.config.ts                      # WXT + Vite + Preact configuration
├── entrypoints/
│   ├── background.ts                  # Service worker
│   │                                  #   - Opens side panel on action click
│   │                                  #   - Relays messages between content ↔ side panel
│   │
│   ├── sidepanel/                     # Side panel UI (Preact)
│   │   ├── index.html                 #   Entry HTML
│   │   ├── main.tsx                   #   Preact render mount
│   │   ├── App.tsx                    #   Root component — layout + state wiring
│   │   ├── components/
│   │   │   ├── ResponseView.tsx       #   Renders AI response with annotation overlays
│   │   │   ├── AnnotationToolbar.tsx  #   Floating ✓ / ✗ / ⤵ / ? / ↺ toolbar
│   │   │   ├── AnnotationList.tsx     #   Summary list with delete buttons
│   │   │   └── StatusBar.tsx          #   Connection status, "Listening for responses..."
│   │   ├── state/
│   │   │   └── annotations.ts        #   Preact Signals — all annotation state + actions
│   │   └── styles/
│   │       ├── sidepanel.css          #   Global layout, typography
│   │       └── annotations.css        #   Annotation visual treatment (highlight, strikethrough, deeper, verify)
│   │
│   ├── claude.content.ts              # Content script for claude.ai
│   │                                  #   - MutationObserver on data-is-streaming
│   │                                  #   - Response HTML extraction
│   │                                  #   - Proactive feedback injection into ProseMirror
│   │
│   ├── chatgpt.content.ts            # Content script for chat.com / chatgpt.com
│   │                                  #   - MutationObserver + stop button detection
│   │                                  #   - Response HTML extraction from article elements
│   │                                  #   - Proactive feedback injection into ProseMirror
│   │
│   ├── copilot.content.ts            # Content script for copilot.microsoft.com
│   │                                  #   - Stop button streaming detection
│   │                                  #   - 1200ms post-streaming debounce
│   │                                  #   - Textarea injection via native setter
│   │
│   └── copilot-enterprise.content.ts # Content script for m365.cloud.microsoft/chat
│                                      #   - Stop button streaming detection (aria-busy unreliable)
│                                      #   - Lexical editor injection via ClipboardEvent paste
│                                      #   - Injection flag suppresses observer during paste
├── lib/
│   ├── types.ts                       # Shared TypeScript types (Annotation, Message, etc.)
│   ├── formatter.ts                   # Annotations → structured feedback text
│   └── platforms/
│       ├── claude.ts                  # claude.ai DOM selectors and extraction/injection logic
│       ├── chatgpt.ts                # ChatGPT DOM selectors and extraction/injection logic
│       ├── copilot.ts                # Copilot consumer DOM selectors, textarea injection
│       └── copilot-enterprise.ts     # Copilot enterprise DOM selectors, Lexical editor injection
└── public/
    └── icons/                         # Extension icons (16, 32, 48, 128px)

WXT conventions: - Files in entrypoints/ are auto-discovered. background.ts becomes the service worker, sidepanel/index.html becomes the side panel, content/claude.ts becomes a content script. - Files in lib/ and components/ are shared utilities, not entrypoints. - WXT auto-generates the manifest from entrypoint metadata and wxt.config.ts.


4. Component Architecture

Component tree

App.tsx
├── StatusBar                    # "Connected to claude.ai" / "Waiting for response..."
├── ResponseView                 # The AI response with annotation overlays
│   └── (rendered HTML with <mark> and <del> wrappers via DOM overlay)
├── AnnotationToolbar            # Floating toolbar (appears on text selection)
│   ├── ✓ Highlight button
│   ├── ✗ Strikethrough button
│   ├── ⤵ Dig deeper button
│   ├── ? Verify button
│   └── ↺ Clear button (when selection overlaps existing annotation)
├── AnnotationList               # "3 highlights, 1 strikethrough" + item list
│   └── AnnotationItem × N      # Each annotation with type icon + text preview + delete
└── Feedback indicator           # "Annotations will be included in your next message"

Component responsibilities

Component Input Output Key behavior
App Messages from background Listens for RESPONSE_READY messages, manages top-level state
StatusBar Connection state signal Shows platform detection status and response state
ResponseView Response HTML + annotations signal Selection events Renders sanitized HTML, overlays annotations as <mark>/<del> wrappers, emits text selection events
AnnotationToolbar Selection position + overlap state Annotation actions Positions near selection, dispatches addAnnotation/removeAnnotation
AnnotationList Annotations signal Remove actions Displays all annotations, click-to-scroll, delete buttons
Feedback indicator Annotations signal Shows "Annotations will be included in your next message" when annotations exist

Data flow

Platform DOM (claude.ai / ChatGPT / Copilot / Copilot Enterprise)
    ↓ (MutationObserver detects response completion)
Content Script (claude / chatgpt / copilot / copilot-enterprise .content.ts)
    ↓ (browser.runtime.sendMessage)
Background (background.ts)
    ↓ (relay)
Side Panel (App.tsx)
    ↓ (updates currentResponse signal)
ResponseView
    ↓ (user selects text)
AnnotationToolbar
    ↓ (user clicks ✓, ✗, ⤵, or ?)
annotations signal updates
    ↓ (auto — PENDING_FEEDBACK message)
Background (relay)
    ↓ (browser.tabs.sendMessage)
Content Script
    ↓ (proactive injection into editor)
Platform text box (feedback auto-appears, user types after it)

5. State Management

All state lives in Preact Signals, defined in state/annotations.ts. Signals provide fine-grained reactivity — when one annotation changes, only the components reading that specific data re-render.

State model

// lib/types.ts

interface Annotation {
  id: string;                          // crypto.randomUUID()
  type: 'highlight' | 'strikethrough' | 'deeper' | 'verify';
  text: string;                        // The selected text
  startOffset: number;                 // Character offset in response text
  endOffset: number;                   // Character offset in response text
  createdAt: number;                   // Date.now()
}

interface ResponseData {
  html: string;                        // Sanitized HTML from claude.ai
  text: string;                        // Plain text (for offset calculations)
  messageId: string;                   // Unique ID for this response
  timestamp: number;
}

type ConnectionStatus = 'disconnected' | 'connected' | 'streaming' | 'ready' | 'error';

type Theme = 'light' | 'dark';

// Message types for chrome.runtime messaging
type ExtensionMessage =
  | { type: 'RESPONSE_READY'; html: string; text: string; messageId: string }
  | { type: 'INJECT_FEEDBACK'; feedback: string }
  | { type: 'PENDING_FEEDBACK'; feedback: string | null }
  | { type: 'CONNECTION_STATUS'; status: ConnectionStatus }
  | { type: 'THEME_CHANGED'; theme: Theme };

Signals

// state/annotations.ts

import { signal, computed, batch } from '@preact/signals';

// Core state
export const annotations = signal<Annotation[]>([]);
export const currentResponse = signal<ResponseData | null>(null);
export const connectionStatus = signal<ConnectionStatus>('disconnected');

// Derived state (auto-updates when dependencies change)
export const highlights = computed(() =>
  annotations.value.filter(a => a.type === 'highlight')
);
export const strikethroughs = computed(() =>
  annotations.value.filter(a => a.type === 'strikethrough')
);
export const hasAnnotations = computed(() =>
  annotations.value.length > 0
);

// Actions
export function addAnnotation(
  type: Annotation['type'],
  text: string,
  startOffset: number,
  endOffset: number
) {
  // Remove any overlapping annotations first
  const filtered = annotations.value.filter(
    a => a.endOffset <= startOffset || a.startOffset >= endOffset
  );
  annotations.value = [...filtered, {
    id: crypto.randomUUID(),
    type,
    text,
    startOffset,
    endOffset,
    createdAt: Date.now(),
  }];
}

export function removeAnnotation(id: string) {
  annotations.value = annotations.value.filter(a => a.id !== id);
}

export function clearAnnotations() {
  annotations.value = [];
}

export function setResponse(data: ResponseData) {
  batch(() => {
    currentResponse.value = data;
    annotations.value = [];  // Clear annotations for new response
  });
}

Key patterns: - Array updates always create new arrays (spread, filter) — signals detect changes by reference. - batch() groups multiple signal updates into one render cycle. - computed() values only recalculate when their dependencies change. - Overlap removal happens in addAnnotation — new annotation replaces any overlapping ones.


6. Messaging Architecture

The side panel cannot directly message content scripts. All communication routes through the background service worker.

Content Script  ──→  Background  ──→  Side Panel
Content Script  ←──  Background  ←──  Side Panel

Message flow: Response detected

1. Content script (claude.ts):
   chrome.runtime.sendMessage({ type: 'RESPONSE_READY', html, text, messageId })

2. Background (background.ts):
   // No relay needed — side panel receives runtime messages directly
   // (Side panel is an extension page, so chrome.runtime.onMessage works)

3. Side panel (App.tsx):
   chrome.runtime.onMessage.addListener((msg) => {
     if (msg.type === 'RESPONSE_READY') setResponse(msg);
   });

Message flow: Proactive feedback injection

When annotations change, the side panel sends PENDING_FEEDBACK with the formatted text (or null when cleared). The content script injects it into the editor if empty or replaces stale feedback.

1. Side panel (App.tsx useEffect on annotations):
   browser.runtime.sendMessage({ type: 'PENDING_FEEDBACK', feedback: formattedText })

2. Background (background.ts):
   // Relays PENDING_FEEDBACK (and legacy INJECT_FEEDBACK) to content script
   browser.runtime.onMessage.addListener((msg) => {
     if (msg.type === 'INJECT_FEEDBACK' || msg.type === 'PENDING_FEEDBACK') {
       browser.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
         if (tab?.id) browser.tabs.sendMessage(tab.id, msg);
       });
     }
   });

3. Content script:
   // On PENDING_FEEDBACK — inject if editor is empty or has stale feedback
   // On null — clear our feedback from editor
   browser.runtime.onMessage.addListener((msg) => {
     if (msg.type === 'PENDING_FEEDBACK') {
       if (msg.feedback) tryInjectFeedback();
       else clearFeedbackFromEditor();
     }
   });

Why this asymmetry? - Content script → Side panel: chrome.runtime.sendMessage reaches all extension pages (side panel is one). No relay needed. - Side panel → Content script: chrome.runtime.sendMessage does NOT reach content scripts. Must use chrome.tabs.sendMessage(tabId) which requires the background to look up the active tab.


7. Platform Integration — claude.ai

DOM selectors

These are reverse-engineered from open-source extensions and subject to change. The extension should handle selector failures gracefully.

// lib/platforms/claude.ts

export const SELECTORS = {
  // Response detection
  responseStreaming: '[data-is-streaming="true"]',
  responseComplete: '[data-is-streaming="false"]',

  // Response content (fallback chain)
  responseContent: [
    '.font-claude-message',
    '[data-testid*="message"]',
    '.prose',
    '[class*="markdown"]',
  ],

  // User messages (to distinguish from assistant)
  userMessage: '[data-testid="user-message"]',

  // Text input (ProseMirror)
  inputEditor: 'div.ProseMirror[contenteditable="true"]',
  inputContainer: 'fieldset > div.cursor-text',

  // Streaming state
  stopButton: 'button[aria-label*="Stop"], button[title*="Stop"]',
};

Response extraction

export function extractLatestResponse(): { html: string; text: string } | null {
  // Get all completed response containers
  const responses = document.querySelectorAll(SELECTORS.responseComplete);
  if (responses.length === 0) return null;

  const latest = responses[responses.length - 1];

  // Find content within the container
  const content = findContentElement(latest);
  if (!content) return null;

  return {
    html: content.innerHTML,
    text: content.textContent || '',
  };
}

Streaming detection

The content script uses a MutationObserver to watch for the data-is-streaming attribute transitioning from "true" to "false":

// Watch for streaming completion
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'attributes' &&
        mutation.attributeName === 'data-is-streaming') {
      const target = mutation.target as HTMLElement;
      if (target.getAttribute('data-is-streaming') === 'false') {
        // Response complete — extract and send
        debounce(() => {
          const response = extractLatestResponse();
          if (response) {
            chrome.runtime.sendMessage({
              type: 'RESPONSE_READY',
              ...response,
              messageId: crypto.randomUUID(),
            });
          }
        }, 500);  // Brief debounce to ensure DOM is settled
      }
    }
  }
});

observer.observe(document.body, {
  attributes: true,
  attributeFilter: ['data-is-streaming'],
  subtree: true,
});

Text injection (ProseMirror)

claude.ai uses ProseMirror for its text input. You cannot simply set .textContent — ProseMirror maintains its own document model.

export function injectIntoTextBox(text: string): boolean {
  const editor = document.querySelector(SELECTORS.inputEditor);
  if (!editor) return false;

  // Create paragraph elements (ProseMirror expects <p> children)
  const lines = text.split('\n');
  for (const line of lines) {
    const p = document.createElement('p');
    p.textContent = line;
    editor.appendChild(p);
  }

  // Dispatch input event so ProseMirror syncs its internal state
  editor.dispatchEvent(new Event('input', { bubbles: true }));
  return true;
}

SPA navigation handling

claude.ai is a single-page app. When the user navigates between conversations, the URL changes but no full page reload occurs. The content script must detect route changes and re-initialize:

// Watch for URL changes (SPA navigation)
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
  if (location.href !== lastUrl) {
    lastUrl = location.href;
    // Re-scan for responses, reset state
    chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status: 'connected' });
  }
});
urlObserver.observe(document.body, { childList: true, subtree: true });

8. Platform Integration — ChatGPT

ChatGPT (chat.com / chatgpt.com) is the second supported platform. It follows the same architecture as claude.ai but with different DOM selectors and detection strategies.

Key differences from claude.ai

Aspect claude.ai ChatGPT
Input editor ProseMirror contenteditable div ProseMirror contenteditable div (same!)
Response containers [data-is-streaming] attribute article elements with .markdown.prose content
Streaming detection data-is-streaming attribute toggle Stop button appears/disappears
Theme html.dark / html.light class html.dark class (dark mode)
URL claude.ai/* chat.com/*, chatgpt.com/*

DOM selectors

// lib/platforms/chatgpt.ts

export const SELECTORS = {
  responseArticle: 'article[data-testid^="conversation-turn-"]',
  responseContent: ['.markdown.prose', '.prose', '[class*="markdown"]'],
  inputEditor: '#prompt-textarea',
  inputEditorFallback: '[contenteditable="true"]',
  stopButton: 'button[aria-label="Stop generating"], button[data-testid="stop-button"]',
};

Streaming detection

ChatGPT has no data-is-streaming attribute. Instead, the content script watches for the stop button:

const observer = new MutationObserver(() => {
  const streaming = isStreaming(); // checks for stop button presence
  if (streaming && !wasStreaming) {
    wasStreaming = true;  // streaming started
  } else if (!streaming && wasStreaming) {
    wasStreaming = false;  // streaming ended — extract response
    debounce(() => extractAndSend(), 500);
  }
});
observer.observe(document.body, { childList: true, subtree: true });

Text injection

ChatGPT also uses ProseMirror (same div.ProseMirror[contenteditable="true"]#prompt-textarea). The injection approach handles both textarea and contenteditable elements:

export function setEditorContent(text: string): boolean {
  const editor = getEditor();
  if (!editor) return false;

  if (isTextarea(editor)) {
    // Textarea path — native value setter to bypass React
    nativeSetter.call(editor, text);
  } else {
    // Contenteditable path — rebuild with <p> elements
    editor.innerHTML = '';
    for (const line of text.split('\n')) {
      const p = document.createElement('p');
      p.textContent = line || '\u200B';
      editor.appendChild(p);
    }
  }
  editor.dispatchEvent(new Event('input', { bubbles: true }));
  return true;
}

9. Platform Integration — Copilot Consumer

Copilot consumer (copilot.microsoft.com) uses a standard textarea for input and a stop button for streaming detection.

Key differences from other platforms

Aspect Copilot Consumer
Input editor <textarea#userInput>
Response containers div.cib-message-group with response content elements
Streaming detection Stop button appears/disappears
Theme data-theme attribute on <html>
Post-streaming debounce 1200ms (longer — DOM settles slowly)

Text injection

Copilot consumer uses a standard <textarea> — injection via native value setter (same pattern as ChatGPT textarea path):

export function setEditorContent(text: string): boolean {
  const editor = getEditor() as HTMLTextAreaElement | null;
  if (!editor) return false;
  const nativeSetter = Object.getOwnPropertyDescriptor(
    HTMLTextAreaElement.prototype, 'value'
  )?.set;
  if (nativeSetter) nativeSetter.call(editor, text);
  else editor.value = text;
  editor.dispatchEvent(new Event('input', { bubbles: true }));
  return true;
}

10. Platform Integration — Copilot Enterprise

Enterprise Copilot (m365.cloud.microsoft/chat) has a completely different DOM structure from consumer Copilot. It uses Lexical (Meta's text editor framework) which requires a unique injection approach.

Key differences

Aspect Copilot Enterprise
Input editor Lexical editor (data-lexical-editor="true", #m365-chat-editor-target-element)
Response containers div.fai-CopilotMessage
Streaming detection Stop button only — aria-busy attribute is unreliable (persists after response)
Theme System preference (no theme attribute)

Lexical editor injection

Enterprise Copilot uses Lexical, Meta's text editor framework. Lexical maintains its own internal state tree and reverts all direct DOM manipulationinnerHTML, textContent, execCommand, synthetic InputEvents all get overwritten.

The only reliable injection method is synthetic ClipboardEvent paste with DataTransfer:

export function setEditorContent(text: string): boolean {
  const editor = getEditor();
  if (!editor) return false;

  editor.focus();

  // Select all existing content in the DOM
  const sel = document.getSelection();
  if (sel) {
    const range = document.createRange();
    range.selectNodeContents(editor);
    sel.removeAllRanges();
    sel.addRange(range);
  }

  // Lexical manages its own internal selection state separately from the DOM.
  // Fire selectionchange so Lexical syncs its internal selection to match our
  // "select all" above, then paste after a tick so Lexical has time to process.
  document.dispatchEvent(new Event('selectionchange'));

  setTimeout(() => {
    const dt = new DataTransfer();
    dt.setData('text/plain', text);
    editor.dispatchEvent(new ClipboardEvent('paste', {
      clipboardData: dt,
      bubbles: true,
      cancelable: true,
    }));
  }, 10);

  return true;
}

Key details: - The selectionchange event dispatch + 10ms delay before paste is critical — without it, Lexical's internal selection doesn't match the DOM selection, and paste appends instead of replacing. - The content script uses an injecting flag to suppress its MutationObserver during paste. Without this, our own DOM changes trigger false streaming detection. - pendingFeedback is cleared when streaming starts to prevent stale feedback re-injection after the user sends a message.

Diagnostic scripts

Three diagnostic scripts in scripts/ were created to debug platform integration:

  • inspect-platform.js — General DOM inspector for adding new platform support
  • inspect-editor.js — Editor element diagnostic for contenteditable injection debugging
  • inspect-lexical.js — Lexical editor diagnostic (tests __lexicalEditor API and ClipboardEvent paste)

11. Annotation UX

Floating toolbar

When the user selects text in the ResponseView, a floating toolbar appears near the selection:

┌───────────────────────┐
│  ✓   ✗   ⤵   ?   ↺  │
└───────────────────────┘
Icon Color Action When visible
Green (#22c55e) Highlight — carry forward Always (when text selected)
Red (#ef4444) Strikethrough — discard Always (when text selected)
Blue (#3b82f6) Dig deeper — elaborate Always (when text selected)
? Amber (#f59e0b) Verify — fact-check Always (when text selected)
Gray (#9ca3af) Clear annotation Only when selection overlaps existing annotation

Positioning: The toolbar appears above the selection, centered horizontally. Uses position: fixed with coordinates from Range.getBoundingClientRect(). If too close to the top of the panel, it appears below the selection instead.

Dismissal: The toolbar disappears when the user clicks outside, makes a new selection, or scrolls.

Visual treatment of annotations

Annotation Visual
Highlight background-color: rgba(34, 197, 94, 0.2) (light green), border-left: 3px solid #22c55e
Strikethrough text-decoration: line-through, opacity: 0.5, background-color: rgba(239, 68, 68, 0.1) (light red)
Dig deeper background-color: rgba(59, 130, 246, 0.2) (light blue), border-left: 3px solid #3b82f6
Verify background-color: rgba(245, 158, 11, 0.2) (light amber), border-left: 3px solid #f59e0b

Annotations are rendered by wrapping the annotated text ranges in <mark> (highlight, deeper, verify) or <del> (strikethrough) elements within the ResponseView.

Annotation list

Below the response view, a collapsible list shows all annotations:

Annotations (3 highlights, 1 strikethrough, 1 explore, 1 verify)
─────────────────────────────────────
✓  "The stateless API approach eliminates..."     [×]
✓  "Token cost is approximately 2.5-3x..."        [×]
✓  "Each model benefits from..."                  [×]
✗  "For straightforward questions..."             [×]
⤵  "The synthesis prompt design..."               [×]
?  "Cost per turn is approximately $0.02..."      [×]
  • Each item shows the annotation type icon, a text preview (truncated), and a delete button.
  • Clicking an item scrolls the ResponseView to that annotation.
  • Clicking the delete button removes the annotation.

Removing annotations — three mechanisms

  1. Click annotated text in the ResponseView → toggles it off directly
  2. Select text overlapping an annotation → toolbar appears with ↺ (clear) button
  3. Click delete in the AnnotationList → removes by ID

Proactive injection flow (zero-click)

  1. User annotates text in the side panel
  2. Formatted feedback auto-injects into the platform's text box immediately
  3. User types their message after the [Your message below] marker
  4. User sends normally via the platform's own send button/Enter key
  5. When annotations are cleared or a new response arrives, feedback is removed from the text box

No "Apply" or "Inject" buttons — the flow is completely automatic. The side panel shows a subtle indicator: "Annotations will be included in your next message."


12. Structured Feedback Format

Annotations are continuously converted to structured text and proactively injected into the platform's text box above whatever the user types next.

Format

[Feedback on your previous response]

KEEP — I found these points valuable:
- "The stateless API approach eliminates complexity of managing separate threads..."
- "Token cost is approximately 2.5-3x, not the naive 4x..."

DROP — Please disregard or reconsider:
- "For straightforward questions this adds no value..."

EXPLORE DEEPER — Need more detail on:
- "The synthesis prompt design..."

VERIFY — Please double-check:
- "Cost per turn is approximately $0.02..."

[Your message below]

Rules

  • Highlighted text → listed under "KEEP"
  • Struck-through text → listed under "DROP"
  • Dig deeper text → listed under "EXPLORE DEEPER"
  • Verify text → listed under "VERIFY"
  • Sections with no annotations of that type are omitted
  • Text is quoted verbatim from the selection
  • Selections longer than 200 characters are truncated with "..."
  • A separator line signals where the user's own message begins

Formatter logic

// lib/formatter.ts

export function formatFeedback(annotations: Annotation[]): string {
  if (annotations.length === 0) return '';

  const highlights = annotations.filter(a => a.type === 'highlight');
  const strikethroughs = annotations.filter(a => a.type === 'strikethrough');
  const deeper = annotations.filter(a => a.type === 'deeper');
  const verify = annotations.filter(a => a.type === 'verify');

  const parts: string[] = ['[Feedback on your previous response]\n'];

  if (highlights.length > 0) {
    parts.push('KEEP — I found these points valuable:');
    for (const h of highlights) {
      parts.push(`- "${truncate(h.text, 200)}"`);
    }
    parts.push('');
  }

  if (strikethroughs.length > 0) {
    parts.push('DROP — Please disregard or reconsider:');
    for (const s of strikethroughs) {
      parts.push(`- "${truncate(s.text, 200)}"`);
    }
    parts.push('');
  }

  if (deeper.length > 0) {
    parts.push('EXPLORE DEEPER — Need more detail on:');
    for (const d of deeper) {
      parts.push(`- "${truncate(d.text, 200)}"`);
    }
    parts.push('');
  }

  if (verify.length > 0) {
    parts.push('VERIFY — Please double-check:');
    for (const v of verify) {
      parts.push(`- "${truncate(v.text, 200)}"`);
    }
    parts.push('');
  }

  parts.push('[Your message below]');
  return parts.join('\n');
}

13. Extensibility

Annotation type extensibility

The architecture supports new annotation types with minimal changes. V1.1 added "dig deeper" and "verify" following this pattern:

1. Type union (lib/types.ts):

type AnnotationType = 'highlight' | 'strikethrough' | 'deeper' | 'verify';

2. TYPE_CONFIG map (ResponseView.tsx):

const TYPE_CONFIG: Record<AnnotationType, { tag: string; cls: string }> = {
  highlight:     { tag: 'mark', cls: 'annotation-highlight' },
  strikethrough: { tag: 'del',  cls: 'annotation-strikethrough' },
  deeper:        { tag: 'mark', cls: 'annotation-deeper' },
  verify:        { tag: 'mark', cls: 'annotation-verify' },
};

3. Toolbar button + CSS + formatter section — each new type gets a button in AnnotationToolbar, a visual treatment in annotations.css, an icon in AnnotationList, and a section in the formatter output.

Adding new platforms

Each platform needs two files (see claude.ai and ChatGPT as templates):

  1. A platform module: lib/platforms/<name>.ts — DOM selectors, extractLatestResponse(), isStreaming(), getEditor(), setEditorContent(), clearEditor()
  2. A content script: entrypoints/<name>.content.tsmatches URL patterns, theme detection, streaming observation, proactive feedback injection

The side panel, state management, and formatter are platform-agnostic — they receive HTML and emit text. Only the content scripts are platform-specific.


14. Known Risks & Mitigations

Selector fragility

Risk: claude.ai updates its DOM structure, breaking response detection or text injection.

Mitigation: - Use data-is-streaming as the primary selector (most stable — it's a semantic attribute, not a styling class) - Implement a fallback chain for content selectors (try multiple selectors in order) - Fail gracefully — show "Could not detect response" in the side panel, allow manual refresh - Future: consider remote selector config (hosted JSON file) so selectors can be updated without republishing the extension

ProseMirror injection

Risk: ProseMirror's internal document model may not sync with DOM manipulation, especially after claude.ai updates.

Mitigation: - Use the established pattern: create <p> elements + dispatch input event (proven in multiple open-source extensions) - If injection fails, show the structured text in a copyable format so the user can paste manually - Test injection after every claude.ai update

SPA navigation

Risk: claude.ai's single-page navigation means the content script's state can become stale when switching conversations.

Mitigation: - Monitor URL changes via MutationObserver - Re-scan for responses on navigation - Send CONNECTION_STATUS updates to the side panel

Text offset drift

Risk: Annotation offsets (start/end character positions) may not match after the response HTML is re-rendered in the side panel.

Mitigation: - Compute offsets relative to the side panel's own rendered text, not the original page DOM - Use the side panel's text content as the single source of truth for offset calculations

Extension review

Risk: Chrome Web Store review may flag permissions or behavior.

Mitigation: - Minimal permissions: sidePanel, activeTab only - No network requests, no data collection - All processing is client-side - Clear privacy description in the store listing


This document captures the Chrome extension architecture as of March 4, 2026. The extension is built and working with claude.ai, ChatGPT, Copilot consumer, and Copilot enterprise support and proactive injection flow. For the VS Code extension variant, see VS Code Extension Architecture.