Chrome extensions are different from anything we have built so far. There is a specific architecture (background scripts, content scripts, popups), a manifest file that Chrome parses, and browser APIs that work differently from regular web APIs.

This is a good stress test for Claude Code. Can it handle Manifest V3 correctly? Does it know the Chrome extension APIs?

The project: a tab manager extension called “TabFlow” that shows all open tabs, lets you search them, close duplicates, and save sessions for later.

Total time: 67 minutes. This was the hardest project in the series so far.

What We’re Building

A Chrome extension with:

  • Popup showing all open tabs across all windows
  • Search and filter tabs by title or URL
  • Close tabs directly from the popup
  • Group tabs by domain
  • “Close Duplicates” button to find and close tabs with the same URL
  • “Save Session” to save all tabs and restore them later
  • Keyboard shortcut (Ctrl+Shift+F) to open the popup
  • Clean, dark-themed design

Tech stack: JavaScript, HTML, CSS, Chrome Extension APIs, Manifest V3

Prerequisites

  • Claude Code installed and configured
  • Chrome or Chromium-based browser
  • Basic JavaScript knowledge

The Build Session

Total time: 67 minutes. 7 prompts. This one required more iterations than the previous projects.

Prompt 1: Core Extension (Minute 0)

Create a Chrome extension called "TabFlow" using Manifest V3. The extension
should have a popup that shows all open tabs across all windows.

For each tab, show: favicon, title (truncated), URL (truncated), and a close
button. Add a search input at the top that filters tabs by title or URL in
real time.

Files needed: manifest.json, popup.html, popup.js, popup.css.

Use a clean, dark design. Make the popup 400px wide and 500px tall.

What Claude generated:

Four files. The manifest:

{
  "manifest_version": 3,
  "name": "TabFlow",
  "version": "1.0.0",
  "description": "Manage your tabs efficiently — search, group, and organize.",
  "permissions": ["tabs"],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

The popup JavaScript:

document.addEventListener("DOMContentLoaded", () => {
  const searchInput = document.getElementById("search");
  const tabList = document.getElementById("tab-list");
  const tabCount = document.getElementById("tab-count");

  let allTabs = [];

  async function loadTabs() {
    allTabs = await chrome.tabs.query({});
    renderTabs(allTabs);
  }

  function renderTabs(tabs) {
    tabCount.textContent = `${tabs.length} tabs`;
    tabList.innerHTML = "";

    tabs.forEach(tab => {
      const item = document.createElement("div");
      item.className = "tab-item";
      item.innerHTML = `
        <img class="tab-favicon" src="${tab.favIconUrl || 'chrome://favicon/'}" alt=""
             onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22><rect fill=%22%23666%22 width=%2216%22 height=%2216%22 rx=%223%22/></svg>'">
        <div class="tab-info">
          <div class="tab-title">${escapeHtml(tab.title || "Untitled")}</div>
          <div class="tab-url">${escapeHtml(truncateUrl(tab.url || ""))}</div>
        </div>
        <button class="tab-close" data-tab-id="${tab.id}" title="Close tab">&times;</button>
      `;

      // Click to switch to tab
      item.addEventListener("click", (e) => {
        if (!e.target.classList.contains("tab-close")) {
          chrome.tabs.update(tab.id, { active: true });
          chrome.windows.update(tab.windowId, { focused: true });
        }
      });

      tabList.appendChild(item);
    });

    // Close button handlers
    document.querySelectorAll(".tab-close").forEach(btn => {
      btn.addEventListener("click", async (e) => {
        e.stopPropagation();
        const tabId = parseInt(e.target.dataset.tabId);
        await chrome.tabs.remove(tabId);
        loadTabs();
      });
    });
  }

  function truncateUrl(url) {
    try {
      const parsed = new URL(url);
      return parsed.hostname + (parsed.pathname !== "/" ? parsed.pathname : "");
    } catch {
      return url.substring(0, 50);
    }
  }

  function escapeHtml(text) {
    const div = document.createElement("div");
    div.textContent = text;
    return div.innerHTML;
  }

  // Search
  searchInput.addEventListener("input", () => {
    const query = searchInput.value.toLowerCase();
    const filtered = allTabs.filter(tab =>
      (tab.title || "").toLowerCase().includes(query) ||
      (tab.url || "").toLowerCase().includes(query)
    );
    renderTabs(filtered);
  });

  loadTabs();
});

The CSS was minimal but effective — dark background, subtle borders, hover effects. Claude chose a #1e1e2e background (similar to Catppuccin Mocha theme) which looked good.

My review: I loaded the extension in Chrome by going to chrome://extensions, enabling Developer Mode, and clicking “Load unpacked.”

It worked immediately. All open tabs showed with their favicons, titles, and URLs. The search filtered in real time. Clicking a tab switched to it. The close button removed tabs.

Two issues: First, the manifest referenced icon files in icons/ directory, but Claude did not create any icons. Chrome showed the default puzzle piece icon instead. Not a crash, but not polished.

Second, the code used chrome://favicon/ as a fallback for missing favicons. In Manifest V3, this requires the "favicon" permission in the manifest. Without it, Chrome blocks the request silently due to Content Security Policy. Claude did not include this permission. The onerror fallback SVG masked the issue, so it was not immediately obvious.

Time spent: 8 minutes

Prompt 2: Group by Domain (Minute 8)

Add a "Group by Domain" toggle button at the top. When enabled, group tabs
by their domain name. Show the domain as a collapsible header with a count
badge. Clicking the header expands or collapses that group. Sort domains
alphabetically.

What Claude generated:

Claude added a toggle and grouping logic:

let groupByDomain = false;

function renderGroupedTabs(tabs) {
  const groups = {};
  tabs.forEach(tab => {
    let domain;
    try {
      domain = new URL(tab.url).hostname;
    } catch {
      domain = "Other";
    }
    if (!groups[domain]) groups[domain] = [];
    groups[domain].push(tab);
  });

  const sortedDomains = Object.keys(groups).sort();
  tabList.innerHTML = "";

  sortedDomains.forEach(domain => {
    const group = groups[domain];
    const header = document.createElement("div");
    header.className = "domain-header";
    header.innerHTML = `
      <span class="domain-arrow">&#9660;</span>
      <span class="domain-name">${escapeHtml(domain)}</span>
      <span class="domain-count">${group.length}</span>
    `;

    const container = document.createElement("div");
    container.className = "domain-group";

    group.forEach(tab => {
      // Same tab item creation as before
      const item = createTabItem(tab);
      container.appendChild(item);
    });

    header.addEventListener("click", () => {
      container.classList.toggle("collapsed");
      header.querySelector(".domain-arrow").textContent =
        container.classList.contains("collapsed") ? "\u25B6" : "\u25BC";
    });

    tabList.appendChild(header);
    tabList.appendChild(container);
  });
}

Claude refactored the tab item creation into a createTabItem function to avoid code duplication. Good practice.

Time spent: 5 minutes

Prompt 3: Close Duplicates (Minute 13)

Add a "Close Duplicates" button at the top. When clicked, find all tabs with
the same URL (exact match) and close all but the first one. Show a count of
how many duplicates were closed in a small notification that fades out after
2 seconds.

What Claude generated:

async function closeDuplicates() {
  const tabs = await chrome.tabs.query({});
  const seen = new Map();
  const toClose = [];

  tabs.forEach(tab => {
    if (seen.has(tab.url)) {
      toClose.push(tab.id);
    } else {
      seen.set(tab.url, tab.id);
    }
  });

  if (toClose.length === 0) {
    showNotification("No duplicate tabs found");
    return;
  }

  await chrome.tabs.remove(toClose);
  showNotification(`Closed ${toClose.length} duplicate${toClose.length > 1 ? "s" : ""}`);
  loadTabs();
}

function showNotification(message) {
  const notification = document.getElementById("notification");
  notification.textContent = message;
  notification.classList.add("show");
  setTimeout(() => {
    notification.classList.remove("show");
  }, 2000);
}

Simple and correct. The notification was a fixed-position element that faded in and out:

.notification {
  position: fixed;
  bottom: 10px;
  left: 50%;
  transform: translateX(-50%);
  background: #4c6ef5;
  color: white;
  padding: 8px 16px;
  border-radius: 8px;
  font-size: 13px;
  opacity: 0;
  transition: opacity 0.3s;
  pointer-events: none;
}

.notification.show {
  opacity: 1;
}

Time spent: 4 minutes

Prompt 4: Save and Restore Sessions (Minute 17)

Add session management:
1. "Save Session" button — saves all current tab URLs and titles to
   chrome.storage.local with a timestamp as the session name
2. A sessions panel that lists saved sessions with date and tab count
3. "Restore" button on each session — opens all tabs from that session
4. "Delete" button on each session
5. Maximum 10 saved sessions. When saving the 11th, delete the oldest.

What Claude generated:

This is where Claude started to struggle. The session feature required using chrome.storage.local, which is an async API. Claude wrote the code correctly:

async function saveSession() {
  const tabs = await chrome.tabs.query({});
  const session = {
    id: Date.now().toString(),
    date: new Date().toLocaleString(),
    tabs: tabs.map(t => ({ url: t.url, title: t.title }))
  };

  const result = await chrome.storage.local.get("sessions");
  let sessions = result.sessions || [];
  sessions.unshift(session);

  if (sessions.length > 10) {
    sessions = sessions.slice(0, 10);
  }

  await chrome.storage.local.set({ sessions });
  showNotification(`Saved ${session.tabs.length} tabs`);
  renderSessions();
}

async function restoreSession(sessionId) {
  const result = await chrome.storage.local.get("sessions");
  const sessions = result.sessions || [];
  const session = sessions.find(s => s.id === sessionId);

  if (!session) return;

  for (const tab of session.tabs) {
    await chrome.tabs.create({ url: tab.url, active: false });
  }

  showNotification(`Restored ${session.tabs.length} tabs`);
}

async function deleteSession(sessionId) {
  const result = await chrome.storage.local.get("sessions");
  let sessions = result.sessions || [];
  sessions = sessions.filter(s => s.id !== sessionId);
  await chrome.storage.local.set({ sessions });
  renderSessions();
}

Problem: Claude forgot to add the storage permission to the manifest. Chrome showed an error: Uncaught (in promise) Error: Cannot read properties of undefined (reading 'local').

I asked Claude to fix it:

The session feature fails because chrome.storage.local is not available.
Add the "storage" permission to manifest.json.

Claude added it:

"permissions": ["tabs", "storage"]

After reloading the extension, sessions worked. Save, restore, and delete all functioned correctly. The session list showed the date, tab count, and action buttons.

Another issue: When restoring a session with 20+ tabs, Chrome flagged the extension for opening too many tabs at once. This is not a bug in Claude’s code — it is a Chrome behavior. But it would be nice to add a confirmation dialog. I let it go for now.

Time spent: 10 minutes (including debugging the missing permission)

Prompt 5: Keyboard Shortcut (Minute 27)

Add a keyboard shortcut Ctrl+Shift+F (or Command+Shift+F on Mac) to open
the TabFlow popup. Configure this in the manifest.

What Claude generated:

Claude added the command to the manifest:

"commands": {
  "_execute_action": {
    "suggested_key": {
      "default": "Ctrl+Shift+F",
      "mac": "Command+Shift+F"
    },
    "description": "Open TabFlow"
  }
}

Problem: Ctrl+Shift+F conflicts with Chrome’s built-in “Find in page” on some systems and with “Search all files” in DevTools. Claude chose a conflicting shortcut.

I changed it:

Ctrl+Shift+F conflicts with Chrome's built-in shortcuts. Change the shortcut
to Alt+Shift+T for the tab manager.

Fixed:

"suggested_key": {
  "default": "Alt+Shift+T",
  "mac": "Alt+Shift+T"
}

Time spent: 4 minutes

Prompt 6: Polish the Design (Minute 31)

Improve the popup design:
1. Add a smooth transition when tabs are filtered
2. Highlight the search term in tab titles when searching
3. Add a subtle hover glow on tab items
4. Show the active tab with a left border highlight
5. Add a count of total tabs vs filtered tabs in the header

What Claude generated:

The search highlighting was the most interesting part:

function highlightText(text, query) {
  if (!query) return escapeHtml(text);
  const escaped = escapeHtml(text);
  const regex = new RegExp(`(${escapeRegex(query)})`, "gi");
  return escaped.replace(regex, '<mark class="search-highlight">$1</mark>');
}

function escapeRegex(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

The .search-highlight class:

.search-highlight {
  background: rgba(76, 110, 245, 0.3);
  color: #fff;
  padding: 0 2px;
  border-radius: 2px;
}

The active tab indicator:

.tab-item.active {
  border-left: 3px solid #4c6ef5;
  background: rgba(76, 110, 245, 0.1);
}

Claude detected the active tab by checking tab.active and adding the class accordingly. The hover glow used a box-shadow transition. Everything looked polished.

Time spent: 6 minutes

Prompt 7: Create Simple SVG Icon (Minute 37)

Create a simple SVG icon for the extension. It should be a stylized tab/grid
icon in indigo color. Generate icons at 16x16, 48x48, and 128x128 sizes.
Save them in an icons/ directory. Use inline SVG, not image files — embed
them directly in the data URI format that manifest.json can use.

What Claude generated:

Claude tried to create PNG icons using data URIs. This did not work — Chrome’s manifest requires actual PNG files, not data URIs.

This was a dead end. Chrome extensions need real .png files for icons. Claude cannot generate images.

Workaround: I used a free online tool to create a simple icon and saved it as icons/icon16.png, icon48.png, and icon128.png. This took 5 minutes outside of Claude.

Alternatively, you could use a simple emoji or letter-based PNG generated with any image tool.

Time spent: 8 minutes (including the workaround)

The remaining time was spent on final testing — loading the extension in Chrome, testing every feature, checking for console errors, and verifying the keyboard shortcut.

Total time: 67 minutes.

What Went Right

  • Manifest V3 was correct from the start. Claude used the right structure, correct permissions syntax, and proper action configuration. Manifest V3 is a common pain point, but Claude handled it well.
  • Chrome API usage was solid. chrome.tabs.query, chrome.tabs.remove, chrome.tabs.create, chrome.storage.local — all used correctly with proper async/await patterns.
  • The popup looked professional. Dark theme, smooth hover effects, good spacing. The search highlighting was a nice touch.
  • Duplicate detection was elegant. Simple Map-based approach, no complex logic needed.

What Went Wrong

1. Missing Storage Permission

Claude used chrome.storage.local but forgot to add the "storage" permission to the manifest. This caused a runtime error that was not obvious — it looked like the storage API did not exist.

Lesson: When asking Claude to use browser APIs, explicitly mention “add the required permissions to the manifest.” Chrome extensions silently fail without proper permissions.

2. Conflicting Keyboard Shortcut

Ctrl+Shift+F conflicts with built-in Chrome shortcuts. Claude defaulted to a common key combination without checking for conflicts.

Lesson: Always test keyboard shortcuts yourself. AI does not know what shortcuts your browser already uses.

3. Cannot Generate Icons

Chrome extensions need actual PNG icon files. Claude tried to generate them using SVG data URIs, but this does not work in the manifest. Image generation is outside Claude’s capabilities.

Lesson: Plan for assets. Icons, images, and screenshots need to be created separately. AI builds the code; you provide the visuals.

4. No Input Validation on Session Restore

When restoring a session with many tabs, there is no warning or confirmation. A session with 50 tabs would open 50 new tabs at once, which can slow down or crash Chrome.

Lesson: Always think about edge cases with large data. Ask Claude to add confirmation dialogs for destructive or bulk operations.

The Final Result

A working Chrome extension with:

  • Tab listing with favicons and close buttons
  • Real-time search with text highlighting
  • Group by domain with collapsible sections
  • Close duplicates with notification
  • Save and restore up to 10 sessions
  • Keyboard shortcut (Alt+Shift+T)
  • Dark theme with hover effects

To install it:

  1. Save all files in a folder
  2. Open chrome://extensions in Chrome
  3. Enable “Developer mode” (top right toggle)
  4. Click “Load unpacked” and select the folder
  5. The TabFlow icon appears in your extension bar

Time and Cost

  • Total time: 67 minutes
  • Claude prompts: 7 (plus 3 fixes)
  • Estimated token usage: ~30,000 tokens
  • Hosting cost: $0 (local extension)
  • Dependencies: None (vanilla JavaScript + Chrome APIs)

Chrome Extension Architecture for Beginners

If you have never built a Chrome extension before, here is a quick summary of the architecture that Claude used:

Manifest V3 is the latest extension format. It replaced Manifest V2 in 2024. Key differences:

  • Service workers instead of background pages
  • Declarative content scripts
  • More restrictive permissions model

The files in our extension:

  • manifest.json — the configuration file that tells Chrome about the extension. It lists permissions, popup files, icons, and keyboard shortcuts.
  • popup.html — the HTML that shows when you click the extension icon. This is a regular HTML page with a fixed size.
  • popup.js — the JavaScript that runs inside the popup. It has access to Chrome APIs like chrome.tabs and chrome.storage.
  • popup.css — styling for the popup.

Chrome APIs we used:

  • chrome.tabs.query({}) — get all open tabs across all windows
  • chrome.tabs.remove(tabId) — close a specific tab
  • chrome.tabs.create({url}) — open a new tab
  • chrome.tabs.update(tabId, {active: true}) — switch to a tab
  • chrome.windows.update(windowId, {focused: true}) — bring a window to front
  • chrome.storage.local.get/set — persistent key-value storage

All of these APIs are asynchronous and return Promises. Claude used async/await throughout, which is the correct modern approach.

Common gotchas that Claude got right:

  • Checking tab.favIconUrl before using it (some tabs do not have favicons)
  • Using escapeHtml() to prevent XSS from tab titles
  • The popup closes when you click outside it, so all state is lost. That is why we save sessions to chrome.storage.local.

Common gotchas that Claude missed:

  • The storage permission in the manifest
  • Icon files need to be actual PNG files
  • Keyboard shortcut conflicts with built-in Chrome shortcuts

Prompt Template for Chrome Extensions

Based on this session, here is a reusable first prompt for Chrome extensions:

Create a Chrome extension (Manifest V3) called "[NAME]".

Files: manifest.json, popup.html, popup.js, popup.css

Permissions needed: [LIST_ALL_PERMISSIONS_INCLUDING_STORAGE]

Popup features:
- [FEATURE_1]
- [FEATURE_2]

Design: [DARK/LIGHT], [WIDTH]px wide, [HEIGHT]px tall.

Important:
- Include ALL required permissions in manifest.json
- Handle missing favicons with fallback images
- Escape all user-generated content to prevent XSS
- Use async/await for all Chrome API calls

The key is listing permissions explicitly. Claude will write the code that uses the APIs, but it often forgets to declare the permissions.

Source Code

Full source code: kemalcodes/vibe-coding-projects (branch: chrome-extension-tabflow)

What’s Next?

The final Quick Build article: Build a Discord Bot with Claude — From Prompt to Running Bot. We will build a feature-rich Discord bot with slash commands, moderation tools, and a welcome system. Then deploy it to run 24/7.