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¶
- Overview
- Tech Stack
- Project Structure
- Component Architecture
- State Management
- Messaging Architecture
- Platform Integration — claude.ai
- Platform Integration — ChatGPT
- Platform Integration — Copilot Consumer
- Platform Integration — Copilot Enterprise
- Annotation UX
- Structured Feedback Format
- Extensibility
- 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:
- Content script detects AI response completion on the platform
- Content script extracts response HTML → sends to side panel via background service worker
- Side panel renders the response with annotation capabilities
- User annotates (highlight / strikethrough / dig deeper / verify) via floating toolbar
- Structured feedback auto-injects into the platform's text box (zero-click flow)
- 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.
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 manipulation — innerHTML, 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 supportinspect-editor.js— Editor element diagnostic for contenteditable injection debugginginspect-lexical.js— Lexical editor diagnostic (tests__lexicalEditorAPI 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¶
- Click annotated text in the ResponseView → toggles it off directly
- Select text overlapping an annotation → toolbar appears with ↺ (clear) button
- Click delete in the AnnotationList → removes by ID
Proactive injection flow (zero-click)¶
- User annotates text in the side panel
- Formatted feedback auto-injects into the platform's text box immediately
- User types their message after the
[Your message below]marker - User sends normally via the platform's own send button/Enter key
- 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):
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):
- A platform module:
lib/platforms/<name>.ts— DOM selectors,extractLatestResponse(),isStreaming(),getEditor(),setEditorContent(),clearEditor() - A content script:
entrypoints/<name>.content.ts—matchesURL 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.