If you're building a Chrome extension that works with AI chat platforms — prompt injection, session export, UI customization — you need to know which platform the user is on before you do anything. A content script that injects a prompt into ChatGPT's textarea will silently fail on Claude, which has a completely different DOM.
Here's how to reliably detect the platform from a content script, with patterns that hold up through UI redesigns.
The fastest check: location.hostname
. AI platforms use distinct domains with predictable structures:
const AI_PLATFORM_HOSTS = {
'chatgpt.com': 'chatgpt',
'chat.openai.com': 'chatgpt', // legacy domain
'claude.ai': 'claude',
'gemini.google.com': 'gemini',
'copilot.microsoft.com': 'copilot',
'bard.google.com': 'gemini', // legacy redirect
'poe.com': 'poe',
'character.ai': 'characterai',
};
function detectPlatformByHost() {
return AI_PLATFORM_HOSTS[location.hostname] ?? null;
}
This covers the common case: user is on claude.ai
, hostname matches, platform identified. No DOM access needed.
Edge cases this misses:
For those, fall through to DOM-based detection.
Each platform has distinctive DOM signatures that are more stable than class names or element IDs (which change frequently). Look for structural patterns, not implementation details:
const PLATFORM_SIGNATURES = [
{
platform: 'chatgpt',
checks: [
() => !!document.querySelector('[data-testid="send-button"]'),
() => !!document.querySelector('#prompt-textarea'),
() => document.title.includes('ChatGPT'),
],
},
{
platform: 'claude',
checks: [
() => !!document.querySelector('[data-placeholder*="Reply"]'),
() => !!document.querySelector('.claude-message'),
() => document.title.toLowerCase().includes('claude'),
],
},
{
platform: 'gemini',
checks: [
() => !!document.querySelector('rich-textarea'), // custom element
() => document.title.includes('Gemini'),
],
},
{
platform: 'copilot',
checks: [
() => !!document.querySelector('[data-testid="composer-input"]'),
() => location.hostname.includes('copilot.microsoft.com'),
],
},
];
function detectPlatformByDOM() {
for (const sig of PLATFORM_SIGNATURES) {
const passedChecks = sig.checks.filter(fn => {
try { return fn(); } catch { return false; }
});
if (passedChecks.length >= 2) return sig.platform; // majority vote
}
return null;
}
The majority vote (requiring at least 2 checks to pass) reduces false positives when only one signature element happens to be present on an unrelated page.
Wrapping each check in try/catch
handles DOM state issues — some checks might throw if the element doesn't exist in the way you expect.
function detectPlatform() {
// Tier 1: URL-based (instant)
const byHost = detectPlatformByHost();
if (byHost) return byHost;
// Tier 2: DOM-based (for edge cases)
const byDOM = detectPlatformByDOM();
return byDOM;
}
In practice, tier 1 handles 95%+ of real usage. Tier 2 is the safety net.
AI chat platforms are SPAs. The initial HTML is usually a shell; the actual UI loads asynchronously. Running detection immediately on document_start
will fail — the DOM isn't built yet.
Options:
document_idle
injection (simplest):
// manifest.json
"content_scripts": [{
"matches": ["https://chatgpt.com/*", "https://claude.ai/*", "..."],
"js": ["content.js"],
"run_at": "document_idle"
}]
document_idle
fires after DOMContentLoaded
and after any deferred scripts run, but before images load. For most AI platforms this is sufficient — the UI is rendered by then.
MutationObserver for SPAs:
If the platform changes state after initial load (navigating between conversations, switching models), you need to react to DOM changes:
const observer = new MutationObserver(() => {
const platform = detectPlatform();
if (platform && platform !== currentPlatform) {
currentPlatform = platform;
onPlatformChanged(platform);
}
});
observer.observe(document.body, { childList: true, subtree: false });
subtree: false
is intentional here — you only need to detect major layout changes, not every DOM mutation inside the conversation thread.
Once you know the platform, you need the textarea to inject a prompt:
const INPUT_SELECTORS = {
chatgpt: [
'#prompt-textarea',
'div[contenteditable="true"][data-id="root"]',
],
claude: [
'div[contenteditable="true"].ProseMirror',
'[data-placeholder*="Reply to Claude"]',
],
gemini: [
'rich-textarea .ql-editor',
'div[contenteditable="true"].textarea',
],
copilot: [
'textarea[data-testid="composer-input"]',
'cib-text-input textarea',
],
};
function getInputElement(platform) {
const selectors = INPUT_SELECTORS[platform] ?? [];
for (const selector of selectors) {
const el = document.querySelector(selector);
if (el) return el;
}
return null;
}
ContentEditable divs (used by Claude and Gemini) need different injection code than standard textareas:
function injectPrompt(element, text) {
if (element.tagName === 'TEXTAREA') {
// Standard textarea
element.value = text;
element.dispatchEvent(new Event('input', { bubbles: true }));
} else if (element.contentEditable === 'true') {
// ContentEditable (ProseMirror, Quill, etc.)
element.focus();
document.execCommand('selectAll', false, null);
document.execCommand('insertText', false, text);
}
}
document.execCommand
is technically deprecated but remains the most reliable way to trigger React/Vue reactivity on contentEditable elements. The synthetic event approach (new InputEvent('input', { data: text, bubbles: true })
) doesn't consistently trigger framework state updates.
ChatGPT and Claude both navigate without page reloads (new conversation = SPA navigation, not a full reload). Your detection logic needs to handle this:
let currentPlatform = detectPlatform();
let lastUrl = location.href;
const navObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
// Re-run detection after navigation
setTimeout(() => {
currentPlatform = detectPlatform();
}, 500); // brief delay for new route to render
}
});
navObserver.observe(document, { subtree: true, childList: true });
This is the detection layer under PromptStash — a Chrome extension for saving and reusing prompts across AI platforms. One shortcut inserts your saved prompt wherever you are.
What platform-specific quirks have you run into? The Gemini <rich-textarea>
custom element was the most surprising — it wraps a Quill editor inside a shadow DOM subtree, which breaks standard querySelector behavior.