Build a Smart Chrome Extension for Claude

Build a Chrome extension that autocorrects messages and injects skill context into Claude.

Introduction

30 Second Summary

Every time you type a message to Claude, you're sending it raw. Typos slip through, and you lose time copy-pasting context that Claude needs to give you a great answer.

In this project, you will build a Chrome extension called skilld that automatically fixes your English and injects relevant coding skill instructions into your Claude messages. The entire correction pipeline runs on your machine using Gemini Nano, with no API keys and no data leaving your browser.

What You'll Build

You type a messy message into Claude, press Enter, and skilld silently rewrites your typos and prepends the right coding instructions before Claude ever sees it.

By the end of this project, you'll have:

  • A floating panel below Claude's input that shows your corrected text in real-time as you type, powered entirely by on-device AI running on your M4 chip.
  • A skill matching engine that detects what you're asking about (frontend design, TDD, git workflows) and automatically prepends relevant coding instructions into your message before Claude reads it.
  • A seamless send interception that replaces your raw input with the enhanced version the moment you press Enter, so Claude responds as if you gave it perfect context every time.
  • Secret Mission: a popup UI that lets you manage your skill catalog and add custom skills from any GitHub SKILL.md URL.

Are there any prerequisites?

You'll need Node.js installed (for one CLI command) and Chrome updated to the latest stable version. No prior experience with browser extensions is required.

Before We Start

Before we dive in, let's commit to what you're about to build and why on-device AI matters for a tool that touches every message you send to Claude.

Set Up Your Extension and Enable Chrome AI

Every Chrome extension starts as a folder of files that Chrome reads to know what the extension does and where it runs. Before you can build any features, you need Chrome to recognise your extension and have the on-device AI model ready to use.

In this step, you will create the extension skeleton, flip two Chrome flags to unlock Gemini Nano locally, and confirm everything is wired up by checking AI availability from the DevTools console.

In this step, get ready to:
  • Create the extension file structure with a Manifest V3 configuration.
  • Enable Chrome flags for on-device AI and download the model.
  • Load the extension in developer mode and verify AI availability.
Create the extension file structure

Chrome extensions are just folders containing a manifest.json file (the configuration) plus whatever scripts, styles, and HTML pages the manifest references.

  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir skilld
cd skilld
  • Create the files that the extension needs by running this command:
touch manifest.json background.js content.js styles.css skills-catalog.js popup.html
  • Confirm the files exist by running:
ls

You should see manifest.json, background.js, content.js, styles.css, skills-catalog.js, and popup.html listed in the output.

Not seeing all six files?

Make sure you ran the touch command from inside the skilld folder. If ls shows nothing, run pwd to check your current directory. It should end with Desktop/skilld.

Help me figure out why my files aren't showing up in the skilld folder.

  • Open the skilld folder in your text editor.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the skilld folder on your Desktop, then click Open.
  • Open manifest.json in the editor and paste the following content:
{
  "manifest_version": 3,
  "name": "skilld",
  "version": "1.0.0",
  "description": "Auto-correct and skill injection for Claude chats",
  "permissions": ["storage"],
  "host_permissions": ["https://claude.ai/*"],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["https://claude.ai/*"],
      "js": ["skills-catalog.js", "content.js"],
      "css": ["styles.css"],
      "run_at": "document_idle"
    }
  ],
  "action": {
    "default_popup": "popup.html",
    "default_title": "skilld"
  }
}
  • Open background.js and paste this code:
chrome.runtime.onInstalled.addListener(() => {
  console.log('skilld extension installed');
});

What does this manifest do?

  • manifest_version: 3 declares this as a Manifest V3 extension, the current Chrome extension format.
  • host_permissions grants the extension access to run on claude.ai pages only.
  • content_scripts tells Chrome to inject skills-catalog.js, content.js, and styles.css into every claude.ai page after it finishes loading (document_idle).
  • background.js is a service worker that logs a message when the extension first installs, confirming it loaded.
  • Save both manifest.json and background.js.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full manifest.json:

{
  "manifest_version": 3,
  "name": "skilld",
  "version": "1.0.0",
  "description": "Auto-correct and skill injection for Claude chats",
  "permissions": ["storage"],
  "host_permissions": ["https://claude.ai/*"],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["https://claude.ai/*"],
      "js": ["skills-catalog.js", "content.js"],
      "css": ["styles.css"],
      "run_at": "document_idle"
    }
  ],
  "action": {
    "default_popup": "popup.html",
    "default_title": "skilld"
  }
}

And background.js:

chrome.runtime.onInstalled.addListener(() => {
  console.log('skilld extension installed');
});
Enable Chrome flags for on-device AI

Chrome ships Gemini Nano as a built-in model, but it needs two flags enabled to activate the download and expose the Prompt API to extensions.

  • Open a new Chrome tab.
  • Type chrome://flags/#optimization-guide-on-device-model into the address bar and press Enter.
  • Find the highlighted flag and set it to Enabled using the dropdown.
  • Now type chrome://flags/#prompt-api-for-gemini-nano-multimodal-input into the address bar and press Enter.
  • Set this flag to Enabled as well.
  • Click the Relaunch button that appears at the bottom of the page.

Chrome restarts with both flags active. The Gemini Nano model begins downloading in the background. This can take a few minutes on first use.

What are these flags doing?

The first flag (optimization-guide-on-device-model) tells Chrome to download and enable the on-device Gemini Nano model. The second flag (prompt-api-for-gemini-nano-multimodal-input) exposes the LanguageModel JavaScript API so your extension can prompt the model.

The Prompt API is stable for Chrome extensions from Chrome 138 onwards. You are running Chrome 151 or newer, so flags only ensure the model download is triggered.

What are the hardware requirements?

Gemini Nano requires macOS 13 or later, at least 22 GB of free disk space in your Chrome profile volume, and either a GPU with more than 4 GB VRAM or a CPU with 16 GB RAM and 4 or more cores.

Your M4 Mac with 16 GB of unified memory meets these requirements.

Load the extension and verify AI availability

With flags enabled and the model downloading, it is time to load your extension and confirm everything works together.

  • Type chrome://extensions into the address bar and press Enter.
  • Toggle the Developer mode switch in the top-right corner to ON.
  • Click Load unpacked.
  • Navigate to Desktop and select the skilld folder, then click Select (macOS) or Select Folder (Windows).

You should see the skilld extension card appear on the extensions page with no red error badges.

Seeing an error on the extension card?

If Chrome reports a manifest error, open manifest.json and check for missing commas or mismatched brackets. JSON is strict about trailing commas and quote formatting.

If you see a file-not-found error, confirm that all six files (manifest.json, background.js, content.js, styles.css, skills-catalog.js, popup.html) exist inside the skilld folder.

Help me fix errors when loading my skilld Chrome extension.

  • Open a new tab and navigate to claude.ai.
  • Open DevTools by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).

Before you run the next command, what do you think it will return? The model has been downloading since you enabled the flags.

  • In the DevTools Console, run this command to check AI availability:
await LanguageModel.availability()

✔️ I see "available"

The Gemini Nano model is fully downloaded and ready. Your extension can now use on-device AI. You are all set for the next step.

ⓧ I see "downloadable" or "downloading"

The model is still being fetched. This is normal on first use and can take several minutes depending on your connection.

  • Wait 2-3 minutes, then run the command again.
  • Once it returns available, proceed to the next step.

You can continue to the next step while it downloads. The model will be ready by the time you need it in Step 3.

ⓧ I see "unavailable"

This means Chrome cannot run Gemini Nano on your machine. Check the following:

  • Confirm both flags are set to Enabled at chrome://flags (search for "optimization-guide" and "prompt-api").
  • Confirm you have at least 22 GB of free disk space.
  • Confirm you relaunched Chrome after enabling the flags.

After fixing, close and reopen Chrome fully, then run the availability check again.

Help me troubleshoot why LanguageModel.availability() returns unavailable.

Your extension skeleton is loaded and Chrome's on-device AI is ready. Next up, you will detect Claude's chat input and build the skilld panel that floats alongside it.

Detect Claude's Input and Show the skilld Panel

Your extension is loaded and the on-device AI model is ready. Now you need to solve a practical problem: how does your content script actually find Claude's chat input?

Claude.ai is a React single-page application. Its text input is not a standard <textarea>. It is a contenteditable div powered by ProseMirror. The editor gets mounted and unmounted as you navigate between conversations, so grabbing a reference once on page load is not enough.

In this step, get ready to:
  • Detect Claude's contenteditable input reliably using a MutationObserver.
  • Inject a floating skilld panel with toggle switches into the page.
  • Echo your typed text into the panel as proof of life.
Find Claude's input with MutationObserver

Because Claude re-mounts its editor whenever you switch conversations, your content script needs to watch the DOM continuously. A MutationObserver fires a callback every time child elements are added or removed, letting you detect the editor the instant it appears.

  • Open content.js in your text editor (it is currently empty).
  • Paste the following code to start the extension's main IIFE, declare state variables, and set up the detection logic:
(function skilld() {
  'use strict';

  let inputEl = null;
  let panelEl = null;
  let session = null;
  let correctedText = '';
  let matchedSkill = null;
  let debounceTimer = null;
  let autocorrectEnabled = true;
  let skillsEnabled = true;

  function findInputElement() {
    const primary = document.querySelector('div[contenteditable="true"].ProseMirror');
    if (primary) return primary;
    const fallback = document.querySelector('div[contenteditable="true"][data-placeholder]');
    if (fallback) return fallback;
    return document.querySelector('div[contenteditable="true"]');
  }

  function attachToInput(el) {
    if (inputEl === el) return;
    inputEl = el;
    el.addEventListener('input', onInputChange, { passive: true });
    el.addEventListener('keyup', onInputChange, { passive: true });
    createPanel();
  }

  const observer = new MutationObserver(() => {
    const el = findInputElement();
    if (el) attachToInput(el);
  });

  observer.observe(document.body, { childList: true, subtree: true });

What does this code do?

  • The IIFE wrapper (function skilld() { ... })() keeps all your variables private so they do not collide with Claude's own JavaScript.
  • findInputElement() tries three selectors in order of specificity. The primary selector div[contenteditable="true"].ProseMirror targets Claude's rich-text editor directly.
  • attachToInput() registers event listeners on the detected element and calls createPanel() (which you will define next).
  • The MutationObserver watches the entire page body for DOM changes. Every time React mounts or unmounts components, the observer re-checks for Claude's input.

Next, you will define createPanel() directly below the observer code in the same file.

  • Still in content.js, paste this code below the observer.observe(...) line to create the panel's DOM structure:

  function createPanel() {
    if (panelEl) return;
    panelEl = document.createElement('div');
    panelEl.id = 'skilld-panel';
    panelEl.innerHTML = `
      <div class="skilld-header">
        <span class="skilld-title">skilld</span>
        <label class="skilld-toggle">
          <input type="checkbox" id="skilld-autocorrect" checked>
          <span>Autocorrect</span>
        </label>
        <label class="skilld-toggle">
          <input type="checkbox" id="skilld-skills" checked>
          <span>Skills</span>
        </label>
      </div>
      <div class="skilld-preview" id="skilld-preview"></div>
      <div class="skilld-skill-badge" id="skilld-badge"></div>
    `;
    document.body.appendChild(panelEl);

What does this code do?

createPanel() builds a floating div with an id of skilld-panel and appends it to the page body. The panel contains a header with the extension name, two checkbox toggles (Autocorrect and Skills), a preview area for corrected text, and a badge area for matched skills.

The if (panelEl) return guard ensures the panel is only created once, even if the observer fires multiple times.

Inject the skilld floating panel

The panel's DOM is in place, but it still needs toggle event handlers and the input echo logic. You will add the toggle listeners, then onInputChange(), and close the IIFE.

  • Continue in content.js directly below document.body.appendChild(panelEl); and paste this code to finish createPanel(), define onInputChange(), and close the file:

    document.getElementById('skilld-autocorrect').addEventListener('change', (e) => {
      autocorrectEnabled = e.target.checked;
      if (!autocorrectEnabled) {
        document.getElementById('skilld-preview').textContent = '';
        correctedText = '';
      }
    });

    document.getElementById('skilld-skills').addEventListener('change', (e) => {
      skillsEnabled = e.target.checked;
      if (!skillsEnabled) {
        document.getElementById('skilld-badge').textContent = '';
        matchedSkill = null;
      }
    });
  }

  function onInputChange() {
    if (!inputEl) return;
    const text = inputEl.innerText.trim();
    const preview = document.getElementById('skilld-preview');
    if (preview) preview.textContent = text;
  }

  const el = findInputElement();
  if (el) attachToInput(el);

})();

What does this code do?

  • The two addEventListener('change', ...) handlers sync the checkbox state with autocorrectEnabled and skillsEnabled. When a toggle is unchecked, it clears the corresponding UI area.
  • onInputChange() reads inputEl.innerText (not .value, because this is a contenteditable div) and echoes it into the preview area.
  • The final two lines perform an immediate check on load in case the editor already exists when the script runs.
  • })(); closes the IIFE and immediately executes it.
  • Save content.js.

The JavaScript is complete. Now the panel needs styles to appear as a floating dark card instead of raw unstyled HTML.

  • Open styles.css and paste the panel container, header, and title styles:
#skilld-panel {
  position: fixed;
  bottom: 120px;
  left: 50%;
  transform: translateX(-50%);
  width: 600px;
  max-width: 90vw;
  background: #1a1a2e;
  border: 1px solid #333;
  border-radius: 12px;
  padding: 12px 16px;
  z-index: 99999;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-size: 13px;
  color: #e0e0e0;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}

.skilld-header {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-bottom: 8px;
}

.skilld-title {
  font-weight: 700;
  font-size: 14px;
  color: #7c6ef6;
  margin-right: auto;
}

What do these styles do?

The panel is fixed-positioned 120px from the bottom and horizontally centered. A high z-index of 99999 ensures it sits above Claude's own UI elements. The dark background (#1a1a2e) and subtle border match Claude's dark theme.

  • Continue in styles.css and add the toggle and preview styles below:

.skilld-toggle {
  display: flex;
  align-items: center;
  gap: 6px;
  cursor: pointer;
  font-size: 12px;
  color: #aaa;
}

.skilld-toggle input[type="checkbox"] {
  width: 14px;
  height: 14px;
  accent-color: #7c6ef6;
}

.skilld-preview {
  min-height: 20px;
  padding: 8px;
  background: #0d0d1a;
  border-radius: 8px;
  color: #b8f0b8;
  font-size: 12px;
  line-height: 1.4;
  white-space: pre-wrap;
  word-break: break-word;
}

.skilld-preview:empty {
  display: none;
}
  • Finally, add the skill badge styles at the bottom of styles.css:

.skilld-skill-badge {
  margin-top: 6px;
  font-size: 11px;
  color: #7c6ef6;
  font-weight: 600;
}

.skilld-skill-badge:empty {
  display: none;
}

Why hide empty elements?

The :empty pseudo-class on both .skilld-preview and .skilld-skill-badge hides those sections entirely when they have no text content. This keeps the panel compact until there is something to show.

  • Save styles.css.
  • Switch to Chrome and go to chrome://extensions.
  • Click the reload icon on the skilld extension card.
  • Navigate to claude.ai and start a new chat (or refresh if already there).

You should see a dark floating panel below Claude's input area. It shows the purple "skilld" title and two checkboxes labeled Autocorrect and Skills.

Panel not appearing?

  • Open DevTools (Cmd+Option+I) and check the Console tab for JavaScript errors. A typo in content.js (like a missing backtick in the template literal) will prevent the script from running.
  • Confirm you saved both content.js and styles.css before reloading the extension.
  • Make sure you clicked the reload button on chrome://extensions for the skilld card. Chrome does not auto-reload unpacked extensions on file changes.

help me debug why my skilld panel isn't showing up on Claude

Listen for input and display raw text

The panel is visible, and the onInputChange() handler you already wrote is wired up via attachToInput(). Time to prove the full loop is working.

  • Click into Claude's message input (the area where you normally type a prompt).
  • Before you type anything, predict what you expect to see in the skilld panel below.
  • Type a short test message, such as hello from skilld.

As you type, the panel's dark preview area lights up with green text echoing exactly what you typed. Each keystroke updates the preview in real time.

Why does this work without a send button?

The input and keyup listeners fire on every keystroke inside the contenteditable div. Your onInputChange() reads innerText (the correct property for contenteditable elements, unlike .value which only works on <textarea> and <input>) and writes it into the preview div.

✔️ Awesome, I've got everything!

Great. Double check you have saved both content.js and styles.css.

ⓧ I'd like to double check the full code

Here is the full content.js as it should look at the end of this step:

(function skilld() {
  'use strict';

  let inputEl = null;
  let panelEl = null;
  let session = null;
  let correctedText = '';
  let matchedSkill = null;
  let debounceTimer = null;
  let autocorrectEnabled = true;
  let skillsEnabled = true;

  function findInputElement() {
    const primary = document.querySelector('div[contenteditable="true"].ProseMirror');
    if (primary) return primary;
    const fallback = document.querySelector('div[contenteditable="true"][data-placeholder]');
    if (fallback) return fallback;
    return document.querySelector('div[contenteditable="true"]');
  }

  function attachToInput(el) {
    if (inputEl === el) return;
    inputEl = el;
    el.addEventListener('input', onInputChange, { passive: true });
    el.addEventListener('keyup', onInputChange, { passive: true });
    createPanel();
  }

  const observer = new MutationObserver(() => {
    const el = findInputElement();
    if (el) attachToInput(el);
  });

Continued:

  observer.observe(document.body, { childList: true, subtree: true });

  function createPanel() {
    if (panelEl) return;
    panelEl = document.createElement('div');
    panelEl.id = 'skilld-panel';
    panelEl.innerHTML = `
      <div class="skilld-header">
        <span class="skilld-title">skilld</span>
        <label class="skilld-toggle">
          <input type="checkbox" id="skilld-autocorrect" checked>
          <span>Autocorrect</span>
        </label>
        <label class="skilld-toggle">
          <input type="checkbox" id="skilld-skills" checked>
          <span>Skills</span>
        </label>
      </div>
      <div class="skilld-preview" id="skilld-preview"></div>
      <div class="skilld-skill-badge" id="skilld-badge"></div>
    `;
    document.body.appendChild(panelEl);

Continued:

    document.getElementById('skilld-autocorrect').addEventListener('change', (e) => {
      autocorrectEnabled = e.target.checked;
      if (!autocorrectEnabled) {
        document.getElementById('skilld-preview').textContent = '';
        correctedText = '';
      }
    });

    document.getElementById('skilld-skills').addEventListener('change', (e) => {
      skillsEnabled = e.target.checked;
      if (!skillsEnabled) {
        document.getElementById('skilld-badge').textContent = '';
        matchedSkill = null;
      }
    });
  }

  function onInputChange() {
    if (!inputEl) return;
    const text = inputEl.innerText.trim();
    const preview = document.getElementById('skilld-preview');
    if (preview) preview.textContent = text;
  }

  const el = findInputElement();
  if (el) attachToInput(el);

})();

And here is the full styles.css:

#skilld-panel {
  position: fixed;
  bottom: 120px;
  left: 50%;
  transform: translateX(-50%);
  width: 600px;
  max-width: 90vw;
  background: #1a1a2e;
  border: 1px solid #333;
  border-radius: 12px;
  padding: 12px 16px;
  z-index: 99999;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-size: 13px;
  color: #e0e0e0;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}

.skilld-header {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-bottom: 8px;
}

.skilld-title {
  font-weight: 700;
  font-size: 14px;
  color: #7c6ef6;
  margin-right: auto;
}
.skilld-toggle {
  display: flex;
  align-items: center;
  gap: 6px;
  cursor: pointer;
  font-size: 12px;
  color: #aaa;
}

.skilld-toggle input[type="checkbox"] {
  width: 14px;
  height: 14px;
  accent-color: #7c6ef6;
}

.skilld-preview {
  min-height: 20px;
  padding: 8px;
  background: #0d0d1a;
  border-radius: 8px;
  color: #b8f0b8;
  font-size: 12px;
  line-height: 1.4;
  white-space: pre-wrap;
  word-break: break-word;
}

.skilld-preview:empty {
  display: none;
}

.skilld-skill-badge {
  margin-top: 6px;
  font-size: 11px;
  color: #7c6ef6;
  font-weight: 600;
}

.skilld-skill-badge:empty {
  display: none;
}

Your extension can now see what you type in Claude and reflect it back to you in real time. Next up, you will replace that raw echo with on-device AI autocorrection powered by Gemini Nano.

Wire Up On-Device Autocorrect

Your panel echoes raw text, but every typo still goes through untouched. Chrome's built-in Prompt API gives you access to Gemini Nano running entirely on your M4 chip. No API keys, no cloud calls, no data leaving your machine.

In this step, you'll create an AI session and wire it into the input listener so corrections appear in the panel in real-time. Getting corrected text back into Claude's ProseMirror editor is a harder problem you'll solve in a later step.

In this step, get ready to:
  • Create a LanguageModel session with a correction-focused system prompt.
  • Debounce input and prompt the model for real-time corrections.
  • Handle edge cases like empty input, short text, and model errors.
Create a LanguageModel session with a system prompt

The Prompt API's entry point is LanguageModel.create(). You configure a session once with a system prompt that constrains the model to fix grammar and spelling only.

  • In content.js, find the closing } brace of the createPanel() function.
  • Add the following initSession() function directly below it:
  async function initSession() {
    if (session) return session;
    const availability = await LanguageModel.availability();
    if (availability === 'unavailable') {
      console.warn('skilld: Gemini Nano unavailable on this device');
      return null;
    }
    session = await LanguageModel.create({
      initialPrompts: [
        {
          role: 'system',
          content: 'You are a text corrector. Fix grammar, spelling, and punctuation errors in the user input. Preserve the original meaning, technical terms, code references, and formatting. Return ONLY the corrected text with no explanations, no quotes, no prefixes.'
        }
      ]
    });
    return session;
  }

What does this code do?

  • if (session) return session reuses an existing session instead of creating a new one every time the model is called.
  • LanguageModel.availability() checks whether Gemini Nano is ready on this device. If it returns 'unavailable', the function bails out gracefully.
  • The initialPrompts array with a system role constrains the model to only fix text. It must be the first message in the array.
  • Save content.js.
  • Go to chrome://extensions and click the reload icon on the skilld card.
  • Confirm the extension still shows no errors (no red error badge on the card).

Seeing an error badge on the extension card?

  • Click the error badge to read the message. A syntax error usually means a missing brace or comma in the function you just added.
  • Make sure initSession() is inside the outer (function skilld() { ... })(); wrapper, not outside it.

Still stuck? Why is my Chrome extension showing an error after adding initSession?

Debounce input and prompt the model

Calling the AI model on every single keystroke would flood Gemini Nano with requests. A debounce waits until the user pauses typing for 800ms, then sends the accumulated text to the model in one shot.

  • Still in content.js, add the correctText() function directly below initSession():
  async function correctText(text) {
    // Skip correction if toggle is off or text is too short
    if (!autocorrectEnabled || text.length < 5) {
      correctedText = text;
      return text;
    }
    try {
      const s = await initSession();
      if (!s) return text;
      const result = await s.prompt(text);
      correctedText = result.trim();
      return correctedText;
    } catch (err) {
      console.error('skilld: correction error', err);
      correctedText = text;
      return text;
    }
  }

What does this code do?

  • The text.length < 5 guard skips correction for very short input. Single words or fragments are not worth a model call.
  • session.prompt(text) sends the user's text to Gemini Nano and returns the corrected version.
  • The try/catch ensures that if the model errors out, the original text is preserved instead of crashing the extension.
  • correctedText stores the result for later use when the message is sent.

Now you need to replace the existing onInputChange() function with a debounced version that calls correctText().

  • In content.js, find the existing onInputChange() function. It currently looks like this:
  function onInputChange() {
    if (!inputEl) return;
    const text = inputEl.innerText.trim();
    const preview = document.getElementById('skilld-preview');
    if (preview) preview.textContent = text;
  }
  • Delete that entire function and replace it with this debounced version:
  function onInputChange() {
    if (!inputEl) return;
    const text = inputEl.innerText.trim();

    clearTimeout(debounceTimer);
    if (autocorrectEnabled && text.length >= 5) {
      debounceTimer = setTimeout(async () => {
        const corrected = await correctText(text);
        const preview = document.getElementById('skilld-preview');
        if (preview) {
          preview.textContent = corrected !== text ? corrected : '';
        }
      }, 800);
    } else {
      const preview = document.getElementById('skilld-preview');
      if (preview) preview.textContent = '';
      if (!text) correctedText = '';
    }
  }

What does this code do?

  • clearTimeout(debounceTimer) cancels any pending model call from a previous keystroke. Only the final pause triggers the AI.
  • setTimeout(..., 800) waits 800ms after the last keystroke before calling correctText().
  • corrected !== text ? corrected : '' only shows the preview when the model actually changed something. If your text is already correct, the panel stays empty.
  • The else branch clears the preview when text is too short or empty, keeping the UI clean.
  • Save content.js.
  • Go to chrome://extensions and reload the skilld extension.
  • Navigate to claude.ai and start a new chat.

Before you test, predict: what do you think will appear in the skilld panel when you type "explan how to bild a ract app" and then pause for a second?

  • Click into Claude's input and type: explan how to bild a ract app.
  • Stop typing and wait 1-2 seconds.

The skilld panel should display the corrected text in green: something like "Explain how to build a React app". The model fixed the spelling without changing your meaning.

Panel stays empty after typing?

  • Open DevTools (Cmd+Option+I on macOS) and check the Console tab for errors. A message starting with skilld: Gemini Nano unavailable means the model did not finish downloading. Visit chrome://components and look for "Optimization Guide On Device Model". Click Check for update and wait for it to complete.
  • Make sure your text is at least 5 characters long. Shorter input is intentionally skipped.
  • Confirm the Autocorrect toggle in the panel is checked (enabled).

Still not working? Why is the skilld autocorrect panel not showing corrections?

Test the edge cases

The code already handles several edge cases gracefully. Confirm each one works as expected.

  • Clear Claude's input completely. The panel preview should disappear (no lingering text).
  • Type just 3-4 characters (e.g. hi or ok). The panel should stay empty because text under 5 characters skips the model call entirely.
  • Uncheck the Autocorrect toggle in the panel, then type a sentence with typos. The panel should remain empty.
  • Re-check Autocorrect and type a grammatically correct sentence (e.g. Explain how closures work in JavaScript). The panel should stay empty because the model returns text identical to the input.

Why skip short text and identical output?

Calling the model on every tiny fragment wastes compute and produces noisy results. The 5-character minimum keeps Gemini Nano focused on real sentences.

Hiding the preview when corrected text matches the original avoids showing the learner their own words back. The panel only appears when it has something useful to say.

✔️ Awesome, I've got everything!

Great. Double check you've saved content.js and the extension is reloaded.

ⓧ I'd like to double check the full code

Your content.js is over 30 lines now, so confirm these sections are present in order inside the IIFE:

  • Variable declarations (inputEl, panelEl, session, correctedText, matchedSkill, debounceTimer, autocorrectEnabled, skillsEnabled).
  • findInputElement() with three selector fallbacks.
  • attachToInput(el) that registers event listeners and calls createPanel().
  • The MutationObserver watching document.body.
  • createPanel() building the panel HTML with toggle listeners.
  • initSession() calling LanguageModel.create() with a system prompt.
  • correctText(text) with the <5 char guard and try/catch.
  • onInputChange() with clearTimeout, the 800ms setTimeout, and the else branch that clears the preview.
  • The initial findInputElement() call at the bottom.

Your extension now runs a full AI correction pipeline on-device. Next up, you'll build a skill catalog so skilld can detect what you're asking about and inject relevant context into your prompt.

Add the Skill Catalog and Keyword Matching

Your autocorrect is humming along, fixing typos on-device. But Claude still receives your messages naked, with no helpful context about what you are asking. If you could automatically detect the topic of your message and prepend relevant instructions, Claude would give sharper answers without you copy-pasting anything.

In this step, you will build a skill catalog inspired by the skills.sh ecosystem and wire up a keyword-matching engine. When you type something like "help me with frontend design", the panel will light up with the matched skill name.

In this step, get ready to:
  • Populate the skill catalog with 5 curated skill objects.
  • Write a keyword-matching function that scores input text against skills.
  • Compose an injection payload that wraps skill context around your corrected text.
Build a local skill catalog

Each skill in the catalog has a name, an array of trigger keywords, and a block of markdown instructions that will be injected into Claude's context. You will start with five popular skills that cover common developer tasks.

  • Open skills-catalog.js in your editor. The file is currently empty.
  • Add the opening of the DEFAULT_SKILLS array with the first two skills by pasting this code:
const DEFAULT_SKILLS = [
  {
    name: 'frontend-design',
    keywords: ['frontend', 'ui', 'css', 'design', 'layout', 'responsive', 'tailwind'],
    content: `You are an expert frontend designer. Follow these principles:
- Use semantic HTML elements
- Apply mobile-first responsive design
- Prefer CSS Grid and Flexbox for layouts
- Use consistent spacing scales (4px base)
- Ensure WCAG 2.1 AA accessibility compliance
- Use modern CSS features (container queries, :has(), nesting)
- Keep component files under 200 lines`
  },
  {
    name: 'tdd',
    keywords: ['tdd', 'test', 'testing', 'jest', 'vitest', 'unit test'],
    content: `You follow strict Test-Driven Development:
1. Write a failing test first that describes the desired behavior
2. Write the minimum code to make the test pass
3. Refactor while keeping tests green
- Name tests descriptively: "should [expected behavior] when [condition]"
- One assertion per test when possible
- Use arrange-act-assert pattern
- Mock external dependencies, not the unit under test`
  },

What does this code do?

Each skill object has three fields. name is the identifier that shows up in the panel badge. keywords is the array of trigger words that will be matched against your input text. content is the markdown instruction block that gets prepended to your message for Claude.

The frontend-design skill triggers on words like "ui", "css", or "tailwind". The tdd skill triggers on "test", "jest", or "vitest".

  • Directly below the tdd skill (after its closing },), add the next two skills:
  {
    name: 'code-review',
    keywords: ['review', 'code review', 'pr', 'pull request', 'feedback'],
    content: `You are conducting a thorough code review. Check for:
- Correctness: Does the code do what it claims?
- Security: Input validation, injection risks, auth checks
- Performance: Unnecessary loops, missing indexes, memory leaks
- Readability: Clear naming, appropriate comments, consistent style
- Testing: Are edge cases covered? Are tests meaningful?
- Provide specific, actionable feedback with code examples`
  },
  {
    name: 'git-guardrails',
    keywords: ['git', 'commit', 'branch', 'merge', 'rebase'],
    content: `Follow these git best practices:
- Write commits in imperative mood: "Add feature" not "Added feature"
- Keep commits atomic: one logical change per commit
- Branch naming: feature/, bugfix/, hotfix/ prefixes
- Never force push to shared branches
- Rebase feature branches on main before merging
- Write descriptive PR titles and descriptions`
  },
  • Add the final skill and close the array by pasting this after the git-guardrails skill:
  {
    name: 'improve-codebase-architecture',
    keywords: ['architecture', 'refactor', 'structure', 'organize', 'codebase', 'clean code'],
    content: `You are improving codebase architecture. Apply these principles:
- Single Responsibility: each module/class has one reason to change
- Dependency Inversion: depend on abstractions, not concretions
- Interface Segregation: prefer small, focused interfaces
- Keep coupling low and cohesion high
- Extract shared logic into well-named utility modules
- Use clear directory structure that maps to domain concepts`
  }
];

Why these five skills?

These cover the most common developer workflows: building UIs, writing tests, reviewing code, managing git history, and cleaning up architecture. Each one maps to a real skill file from the skills.sh ecosystem.

The content is kept concise (a few bullet points each) so it adds useful context without consuming too much of Claude's input window.

  • Save skills-catalog.js.
  • Go to chrome://extensions and click the reload icon on the skilld card.
  • Confirm there are no errors shown on the extension card (no red error badge).

Seeing an error on the extension card?

Check that every skill object except the last one ends with a comma after its closing }. The final object (improve-codebase-architecture) should NOT have a trailing comma before ];.

Also confirm that every template literal uses backticks (`) and not regular quotes.

Help me debug my skills-catalog.js syntax

Match input text to skills

The catalog exists, but the content script does not know about it yet. You need a function that reads the catalog and another that scores your typed text against every skill's keywords to find the best match.

  • In content.js, find the correctText function's closing brace. Add these two new functions directly below it:
  function getSkillCatalog() {
    // Read the global DEFAULT_SKILLS array loaded from skills-catalog.js
    return typeof DEFAULT_SKILLS !== 'undefined' ? DEFAULT_SKILLS : [];
  }

  function matchSkillFromText(text) {
    if (!skillsEnabled) return null;
    // Tokenize input into lowercase words
    const tokens = text.toLowerCase().split(/\s+/);
    const catalog = getSkillCatalog();
    let bestMatch = null;
    let bestScore = 0;

    for (const skill of catalog) {
      let score = 0;
      for (const keyword of skill.keywords) {
        // Multi-word keywords must have all tokens present
        const kwTokens = keyword.toLowerCase().split(/\s+/);
        if (kwTokens.every(kt => tokens.some(t => t.includes(kt)))) {
          score += keyword.length;
        }
      }
      if (score > bestScore) {
        bestScore = score;
        bestMatch = skill;
      }
    }
    return bestMatch;
  }

What does this code do?

getSkillCatalog() safely reads the global DEFAULT_SKILLS array. Because skills-catalog.js is loaded before content.js in the manifest, that global is already available.

matchSkillFromText() splits your input into lowercase tokens, then checks each skill's keywords against them. Multi-word keywords (like "code review") only match if ALL their tokens appear in your text. The skill with the highest cumulative keyword-length score wins.

Now you need to call matchSkillFromText every time the user types. Replace the entire onInputChange function with this updated version that runs skill matching before the autocorrect debounce:

  function onInputChange() {
    if (!inputEl) return;
    const text = inputEl.innerText.trim();

    // Match skills on every keystroke (instant, no debounce needed)
    if (skillsEnabled) {
      matchedSkill = matchSkillFromText(text);
      const badge = document.getElementById('skilld-badge');
      if (badge) {
        badge.textContent = matchedSkill ? `Skill: ${matchedSkill.name}` : '';
      }
    }

    clearTimeout(debounceTimer);
    if (autocorrectEnabled && text.length >= 5) {
      debounceTimer = setTimeout(async () => {
        const corrected = await correctText(text);
        const preview = document.getElementById('skilld-preview');
        if (preview) {
          preview.textContent = corrected !== text ? corrected : '';
        }
      }, 800);
    } else {
      const preview = document.getElementById('skilld-preview');
      if (preview) preview.textContent = '';
      if (!text) correctedText = '';
    }
  }

Why match on every keystroke?

Skill matching is a simple string comparison against a small array. It runs in microseconds, so there is no need to debounce it like the AI autocorrect. The badge updates instantly as you type, giving immediate feedback about which skill would be injected.

  • Save content.js.
  • Go to chrome://extensions and click the reload icon on the skilld card.
  • Navigate to claude.ai and start a new chat.
  • Type help me with frontend design into the input.

You should see the purple badge in the skilld panel update to show Skill: frontend-design. Try typing write a unit test and the badge should switch to Skill: tdd.

Badge not appearing?

Make sure you reloaded the extension after saving both files. The content script only re-injects when you reload.

Open DevTools on claude.ai (Cmd+Option+J) and check the Console for errors. A ReferenceError mentioning DEFAULT_SKILLS means skills-catalog.js has a syntax error preventing it from loading.

Help me debug why my skilld badge is not showing

Format the injection payload

The matching works, but the matched skill's content is not yet assembled into a format ready for injection. You need a function that wraps the skill instructions with clear delimiters and appends your corrected text.

  • In content.js, add this function directly below matchSkillFromText:
  function composePayload() {
    let payload = '';
    // Wrap skill content in clear delimiters so Claude knows it's context
    if (matchedSkill && skillsEnabled) {
      payload += `[SKILL CONTEXT: ${matchedSkill.name}]\n${matchedSkill.content}\n[/SKILL CONTEXT]\n\n`;
    }
    // Append the corrected text (or raw text if autocorrect is off)
    payload += correctedText || (inputEl ? inputEl.innerText.trim() : '');
    return payload;
  }

What does this code do?

composePayload() builds the final string that will replace your message before sending. If a skill is matched and the toggle is on, it wraps the skill content in [SKILL CONTEXT: name]...[/SKILL CONTEXT] delimiters. This makes it clear to Claude that the block is instructional context, not part of your question.

The corrected text (from Gemini Nano) is appended after the skill block. If autocorrect produced no changes, the raw input text is used instead.

  • Save content.js and reload the extension from chrome://extensions.

Before you test, what do you expect to see when you type a message containing the word "architecture" into Claude's input?

  • Navigate to claude.ai and type help me refactor my architecture into the chat input.

The badge shows Skill: improve-codebase-architecture. After a moment, the autocorrect preview also appears with any corrections. Both systems are now working together.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both skills-catalog.js and content.js.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these pieces in order:

  • skills-catalog.js contains all 5 skill objects (frontend-design, tdd, code-review, git-guardrails, improve-codebase-architecture) inside the DEFAULT_SKILLS array.
  • content.js has getSkillCatalog() and matchSkillFromText() placed after correctText().
  • content.js has composePayload() placed after matchSkillFromText().
  • The onInputChange() function includes the if (skillsEnabled) block that calls matchSkillFromText and updates the badge element.

You now have a complete skill detection pipeline. The catalog holds expert instructions, the matcher scores your input against keywords, and the payload composer wraps everything into a tidy package. In the next step, you will intercept the Enter key and inject this composed payload directly into Claude's input before sending.

Intercept Send and Inject into Claude

Your skilld panel is showing corrections and skill badges, but they are just sitting there doing nothing. When you press Enter, Claude sends your original typo-filled message without any enhancement.

The missing piece is interception. You need to catch the Enter keypress before Claude processes it, swap the input text with your enhanced payload, and then trigger the send yourself.

In this step, get ready to:
  • Intercept Enter key presses in capturing phase to stop the default send.
  • Inject corrected text and skill context into Claude's ProseMirror editor using the execCommand technique.
  • Trigger the send after injection so Claude receives the enhanced message.
Add the send interception listener

Claude.ai sends messages when you press Enter. Your extension needs to intercept that keypress before Claude sees it, replace the input content, then trigger the send with the enhanced text.

Why use the capturing phase?

Event listeners can fire in two phases: capturing (top-down) and bubbling (bottom-up). By adding the listener with true as the third argument, your handler fires before Claude's own keydown handler.

This gives you a chance to call preventDefault() and stopPropagation() before the message is sent normally.

  • In content.js, scroll down to the bottom of the IIFE, just above the line that says:
  const el = findInputElement();
  if (el) attachToInput(el);
  • Add the following keydown listener directly above those two lines:
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !e.shiftKey && inputEl && document.activeElement === inputEl) {
      const text = inputEl.innerText.trim();
      if (!text) return;

      const hasCorrection = autocorrectEnabled && correctedText && correctedText !== text;
      const hasSkill = skillsEnabled && matchedSkill;

      if (hasCorrection || hasSkill) {
        e.preventDefault();
        e.stopPropagation();

        const payload = composePayload();

        inputEl.focus();
        document.execCommand('selectAll', false, null);
        document.execCommand('insertText', false, payload);

        setTimeout(() => {
          const sendButton = document.querySelector('button[aria-label="Send message"]') ||
                             document.querySelector('button[data-testid="send-button"]') ||
                             document.querySelector('form button[type="submit"]');
          if (sendButton) {
            sendButton.click();
          } else {
            inputEl.dispatchEvent(new KeyboardEvent('keydown', {
              key: 'Enter', code: 'Enter', bubbles: true, cancelable: true
            }));
          }
        }, 50);
      }
    }
  }, true);

What does this code do?

This listener handles the full interception-injection-send cycle in three stages:

  • Detection: It checks for Enter (without Shift, which is a newline) only when the Claude input is focused and a correction or skill match is active.
  • Injection: It calls composePayload() to build the enhanced text, then uses document.execCommand('selectAll') followed by document.execCommand('insertText') to replace the input content in a way that triggers ProseMirror's state update.
  • Send: After a 50ms pause (to let the UI settle), it clicks the send button using multiple fallback selectors, or dispatches a synthetic Enter event if no button is found.
  • Save content.js.

Seeing a syntax error in content.js?

Make sure the new listener is placed above the const el = findInputElement(); line and below the closing brace of the onInputChange() function.

Check that all parentheses and braces match. The listener ends with }, true); on its last line. help me fix a syntax error in my skilld content.js keydown listener

Understand the ProseMirror injection technique

The injection technique in this listener deserves a closer look. Claude.ai uses ProseMirror, a rich-text editor built on a contenteditable div.

Why execCommand instead of innerHTML?

Setting innerHTML or textContent directly on a ProseMirror element does not trigger React's state update. Claude's UI would still think the old text is there, causing the message to send incorrectly or not at all.

The document.execCommand('insertText') method is the proven reliable way to inject text into contenteditable elements powered by frameworks like React or ProseMirror. It simulates real user typing, which triggers all the internal state reconciliation the editor expects.

Reload and test the full skilld flow

Content script changes require both an extension reload and a page refresh to take effect.

  • Switch to your Chrome browser and navigate to chrome://extensions.
  • Click the refresh icon on the skilld extension card to reload it.
  • Navigate to claude.ai and refresh the page.
  • Start a new chat.
  • Type the following message (with deliberate typos and a keyword):
i want to improov my frontend desing
  • Wait about 2 seconds for the panel to show the corrected text and the frontend-design skill badge.

Before you press Enter, what do you think Claude will receive? Will it get your original typo-filled text, or the corrected version with skill context prepended?

  • Press Enter.

Claude receives your corrected message with the frontend-design skill instructions prepended. You should see Claude respond with specific references to the skill guidelines, like semantic HTML, mobile-first design, or WCAG accessibility.

Message sent without corrections?

If Claude received your original typo-filled text, the listener may not be active. Check that you reloaded the extension AND refreshed the claude.ai page.

Open DevTools on the claude.ai tab and check the Console for any errors from content.js. Also confirm the panel is showing a correction before you press Enter. If the panel is empty, the debounce has not finished yet. Wait until you see green corrected text in the panel.

help me debug why my skilld extension is not intercepting the Enter key on claude.ai

✔️ Awesome, I've got everything!

Great. Double check you have saved content.js with the new keydown listener.

ⓧ I'd like to double check the full code

Your content.js file is now quite long. Scroll through it and confirm these sections are present in order:

  • Variable declarations (inputEl, panelEl, session, etc.).
  • findInputElement() and attachToInput() functions.
  • MutationObserver setup.
  • createPanel() function.
  • initSession() and correctText() functions.
  • getSkillCatalog(), matchSkillFromText(), and composePayload() functions.
  • onInputChange() function.
  • The new document.addEventListener('keydown', ...) listener with true as the third argument.
  • The initial findInputElement() call and attachToInput() at the very end.

Your extension is now fully functional. Every message you send on claude.ai gets its typos quietly corrected and relevant skill context prepended, all without a single cloud API call for the correction. Next up, you can take skilld further with the Secret Mission and build a popup for managing your skill catalog.

Secret mission

Build a Popup with Skill Management

Power users want to customize which skills are available and add new ones from GitHub repos on skills.sh without editing code. In this mission, you will build an extension popup with a form for adding custom skills by pasting a raw GitHub URL to any SKILL.md file. The popup persists skills to chrome.storage.local so they survive browser restarts.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud services, so there are no ongoing costs.

Resources you used:

  • The skilld Chrome extension loaded in developer mode.
  • The skilld project folder on your Desktop.
  • Gemini Nano model downloaded to your Chrome profile (managed by Chrome automatically, uses disk space).

Keep everything running

No action needed. Choose this if you are still actively using skilld with Claude.

  • The extension stays loaded in Chrome and costs nothing to run.
  • The Gemini Nano model stays on disk and is shared with other Chrome AI features.
  • Your custom skills persist in chrome.storage.local across browser sessions.

Pause - I'll come back to this later

Disable the extension temporarily without removing it. Your files and custom skills stay intact for when you return.

  • Navigate to chrome://extensions in your browser.
  • Find skilld in the list and toggle the switch to disable it.
  • The extension stops running on claude.ai but stays listed for easy re-enabling later.

Delete - I don't want to use this again

Remove the extension from Chrome and delete the project files from your machine.

  • Navigate to chrome://extensions in your browser.
  • Find skilld and click Remove.
  • Confirm the removal when prompted.
  • Delete the project folder by running this command in your terminal:
rm -rf ~/Desktop/skilld
  • Confirm the folder is gone by running:
ls ~/Desktop | grep skilld

You should see no output, confirming the folder has been deleted.

What about the Gemini Nano model?

The Gemini Nano model is managed by Chrome and shared with other browser AI features. It cannot be individually deleted. Chrome handles its lifecycle automatically.

Nice Work!

Nice Work!

You just built a fully functional Chrome extension that makes every Claude conversation smarter. Your typos get fixed on-device before sending, and Claude automatically receives relevant skill context without you copy-pasting anything.

Here's what you learned:

  • Built a complete Chrome Manifest V3 extension from scratch with content scripts, a service worker, and DOM injection that works on a live React SPA.
  • Used Chrome's built-in Prompt API to run Gemini Nano on-device for real-time text correction with zero cloud calls, no API keys, and no data leaving your browser.
  • Mastered MutationObserver for detecting dynamically mounted inputs and execCommand for injecting text into a ProseMirror contenteditable editor without breaking React state.
  • Secret Mission: Built a popup UI with persistent storage using chrome.storage.local for managing a custom skill catalog sourced from GitHub SKILL.md files.

Ready to quiz yourself?