Skip to content

Recurate Annotator — Chrome Extension Architecture

Status: Implementation-ready Date: February 21, 2026 Target: V1 — claude.ai + ChatGPT (chat.com)


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. Annotation UX
  10. Structured Feedback Format
  11. Extensibility
  12. 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) and strikethrough (drop/discard). When the user is ready, 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 and ChatGPT (chat.com). 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) 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
│   │   │   ├── FeedbackPreview.tsx    #   Preview of structured text before injection
│   │   │   └── StatusBar.tsx          #   Connection status, "Listening for responses..."
│   │   ├── state/
│   │   │   └── annotations.ts        #   Preact Signals — all annotation state + actions
│   │   └── styles/
│   │       ├── sidepanel.css          #   Global layout, typography
│   │       └── annotations.css        #   Highlight/strikethrough visual treatment
│   │
│   ├── 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
├── 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
└── 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
│   └── ↺ 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)
    ↓ (MutationObserver detects response completion)
Content Script (claude.content.ts / chatgpt.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'; // V1.1 adds: 'deeper' | 'question'
  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';

type Theme = 'light' | 'dark';

// Message types for chrome.runtime messaging
type ExtensionMessage =
  | { type: 'RESPONSE_STREAMING'; html: string; messageId: string }
  | { 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. 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)
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)

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

Annotation list

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

Annotations (3 highlights, 1 strikethrough)
─────────────────────────────────────
✓  "The stateless API approach eliminates..."     [×]
✓  "Token cost is approximately 2.5-3x..."        [×]
✓  "Each model benefits from..."                  [×]
✗  "For straightforward questions..."             [×]
  • 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 → toolbar appears with ↺ (clear) button
  2. Click an annotation in the ResponseView → toggles it off directly
  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."


9. Structured Feedback Format

When the user clicks Apply, annotations are converted to structured text. This text gets injected into claude.ai'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..." (not relevant to our discussion)

[Your message below]

Rules

  • Highlighted text → listed under "KEEP"
  • Struck-through text → listed under "DROP"
  • If only highlights exist, the "DROP" section is omitted (and vice versa)
  • 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 {
  const highlights = annotations.filter(a => a.type === 'highlight');
  const strikethroughs = annotations.filter(a => a.type === 'strikethrough');

  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('');
  }

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

10. Extensibility

Adding V1.1 annotation types

The architecture supports new annotation types with minimal changes:

1. Add to the type union:

type: 'highlight' | 'strikethrough' | 'deeper' | 'question'

2. Add a toolbar button:

// AnnotationToolbar.tsx — add to the button array
{ type: 'deeper', icon: '⤵', color: '#3b82f6', label: 'Dig deeper' }
{ type: 'question', icon: '?', color: '#f59e0b', label: 'Verify this' }

3. Add visual treatment:

/* annotations.css */
.annotation-deeper { border-left: 3px solid #3b82f6; background: rgba(59, 130, 246, 0.1); }
.annotation-question { border-left: 3px solid #f59e0b; background: rgba(245, 158, 11, 0.1); }

4. Add to the formatter:

// New sections in structured feedback
'EXPLORE DEEPER — I want to go deeper on these points:'
'VERIFY — I'm not sure about these claims:'

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.


11. 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 complete extension architecture as of February 21, 2026. Updated with ChatGPT support and proactive injection flow. A developer can build from this document and the parent design doc (docs/design.md) without additional context.