Skip to content

SOPs

Chrome Extension — Repo Layout, Build System, Manifest Rules

Every Chrome extension repo has the same shape. Repo root holds docs and the build script. Everything Chrome loads lives in an inner extension/ folder. The bump-build.sh script is the one and only way to change the manifest version and description — do not hand-edit those fields.

This matters because:

  1. Load-unpacked UX. James loads extensions by clicking apps then the repo folder then the extension folder. Same path every time, no hunting.
  2. No accidental shipping. Putting README.md, .git/, CLAUDE.md, and build.txt at repo root keeps them out of the Chrome package. Only files under extension/ ship.
  3. Deterministic manifest. bump-build.sh enforces the version format (1.0.X) and the description format (Build X - summary, capped at 132 chars because Chrome rejects longer).

The Precedent — read-aloud-extension Build 1 vs Build 2, 2026-04-16

Section titled “The Precedent — read-aloud-extension Build 1 vs Build 2, 2026-04-16”

The first read-aloud-extension scaffold put everything at repo root: manifest.json, src/background.js, src/popup.html. It worked, but it broke two conventions:

  • Load-unpacked pointed at the repo root, which meant Chrome was reading README.md, .git/, and CLAUDE.md as part of the extension package.
  • There was no bump-build.sh, so the manifest version and description drifted from build.txt.

Build 2 restructured it: moved everything into extension/, stripped src/ prefixes from the manifest paths, and added bump-build.sh. This SOP exists so the next extension gets it right on the first try.

<repo-name>/ - repo root
├── build.txt - plain integer, source of truth for build number
├── bump-build.sh - the ONLY way to change manifest version/description
├── CLAUDE.md - repo-specific rules
├── README.md - public-facing
├── VISION.md - optional; long-term direction (present in fb-* repos)
├── .gitignore
├── .code-workspace - optional VS Code workspace file
└── extension/ - load THIS folder in chrome://extensions
├── manifest.json
├── background.js - service worker (MV3)
├── content.js - injected into pages
├── popup.html/js/css - toolbar popup UI
├── offscreen.html/js - only if you need audio playback (see below)
└── icons/ - 16, 48, 128 PNGs
  1. manifest_version: 3. Always. MV2 is deprecated.
  2. version is 1.0.X where X is the current build number. bump-build.sh writes this for you.
  3. description is Build X - short summary, capped at 132 characters. Chrome silently rejects longer values on install.
  4. Paths in the manifest are relative to extension/, not the repo root. No src/ prefix.
  5. host_permissions is the narrowest match that still works. <all_urls> is fine for a general-purpose reader like read-aloud-extension, but an FB-only extension should list https://www.facebook.com/*.

Every repo has its own copy of this file at the root. It does three things atomically:

  1. Increments build.txt
  2. Rewrites extension/manifest.json version to 1.0.{new-build}
  3. Rewrites extension/manifest.json description to Build {new-build} - {your message}

It refuses to run if the resulting description would be over 132 chars. That is Chrome’s hard limit.

#!/bin/bash
set -e
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_FILE="$REPO_DIR/build.txt"
MANIFEST="$REPO_DIR/extension/manifest.json"
if [ -z "$1" ]; then echo "Usage: bash bump-build.sh \"Short description\""; exit 1; fi
DESC="$1"
CURRENT=$(cat "$BUILD_FILE" | tr -d '[:space:]')
NEXT=$((CURRENT + 1))
MANIFEST_DESC="Build $NEXT - $DESC"
DESC_LEN=${#MANIFEST_DESC}
if [ "$DESC_LEN" -gt 132 ]; then echo "ERROR: $DESC_LEN chars (max 132): $MANIFEST_DESC"; exit 1; fi
echo "$NEXT" > "$BUILD_FILE"
python3 -c "
import json
with open('$MANIFEST', 'r') as f:
data = json.load(f)
data['version'] = '1.0.$NEXT'
data['description'] = '''$MANIFEST_DESC'''
with open('$MANIFEST', 'w') as f:
json.dump(data, f, indent=2)
f.write('\n')
"
echo ""
echo "=== Build $NEXT ==="
echo "Description: $MANIFEST_DESC ($DESC_LEN chars)"
Terminal window
bash bump-build.sh "Add dark mode toggle"

Then commit with the matching message:

Terminal window
git commit -m "Build 15: Add dark mode toggle"

bump-build.sh is the primary tool, but it relies on a human (or Claude) remembering to run it. For repos where that has bitten — claude-code-chrome-ext shipped five builds (23–27) with a stale manifest version — add a .githooks/pre-commit hook that re-locks the manifest version (and name, for side-panel repos) to build.txt on every commit. Activate with git config core.hooksPath .githooks per clone.

The hook cannot fix description — that needs the commit summary, which a pre-commit hook never sees. It is a safety net for the build number only; bump-build.sh is still the rule.

Icon Generation — Nano Banana Pro on Remote Mac

Section titled “Icon Generation — Nano Banana Pro on Remote Mac”

Default flow for any new Chrome extension’s icon: generate six distinct concepts via image-create on Remote Mac, review on the MacBook Pro, bake the chosen one to 16/48/128. This replaces the old “ship a placeholder, fix it later” pattern that left every extension with the same blue-letter monogram for weeks.

  1. Six concepts, not six renders. Nano Banana Pro’s two-outputs-per-Run are near-identical siblings (see image-create CLAUDE.md → “Variations Means Different Concepts”). Six distinct prompts = six distinct Runs = six creatively different options. Never six prompts of the same scene with tweaks.
  2. Browser backend on Remote Mac, free. generate-image-gemini-browser.py drives Chrome on Remote Mac via SSH, no per-image cost on the existing Google AI Pro plan.
  3. Pick on the MBP. Review HTML lives at the extension repo’s root (icon-review.html), images alongside in icon-review-images/. James clicks a tile to mark his pick, then tells Claude the version number.
  4. Bake server-side. Once chosen, PIL resizes the 1024×1024 jpg to 16/48/128 PNGs with LANCZOS, drops into extension/icons/, and bump-build.sh ships it.
  1. Write six concept prompts at /tmp/<repo-name>-icon-prompts/v1-<concept>.txt through v6-<concept>.txt. Each must:
    • Specify a square 1024×1024 composition with a macOS-style rounded-square outer frame
    • Be a distinct creative concept — different metaphor, different composition, different materials. Not six color variants of the same scene.
    • Explicitly request rich photographic 3D rendering with dimensional shading — Nano Banana defaults flat-illustration without this
    • Have no readable text in the image (Nano Banana garbles type)
    • Anchor the silhouette so it reads clearly at 32px
    • Avoid James’s face, people, and anything domain-specific that won’t compress to a glanceable icon
  2. Build a manifest at /tmp/<repo-name>-icon-manifest.json — JSON array of 6 entries, each:
    {"style": "icon", "concept_slug": "<repo-name>", "prompt_file": "/tmp/<repo-name>-icon-prompts/vN-<concept>.txt", "version": N}
  3. Fire the parallel run:
    Terminal window
    cd ~/apps/image-create && python3 generate-image-gemini-browser.py --manifest /tmp/<repo-name>-icon-manifest.json
    Drives 6 Chrome windows on Remote Mac in parallel. Expect partial failures (AI Studio internal errors hit ~1 in 6 runs in practice). The script reports OK/FAIL per job and exits non-zero if any fail. 5 of 6 is enough to pick from; re-fire the failed concept only if none of the 5 work.
  4. Stage for review — copy outputs from ~/apps/image-create/icon/<repo-name>/ into ~/apps/<repo-name>/icon-review-images/.
  5. Build a review HTML at ~/apps/<repo-name>/icon-review.html — warm cream background, serif type matching the scripture-stories visual identity, 2x3 (or 1x5) grid of tiles, each with version number + one-line concept description + click-to-mark-picked. Footer instructs James to reply with the version number.
  6. SCP to MBP and open:
    Terminal window
    scp ~/apps/<repo-name>/icon-review.html ojhurst@192.168.1.212:~/apps/<repo-name>/
    scp -r ~/apps/<repo-name>/icon-review-images ojhurst@192.168.1.212:~/apps/<repo-name>/
    bash ~/apps/cc/open-html.sh ~/apps/<repo-name>/icon-review.html
    open-html.sh is SSH-aware and forwards the open to the client over SSH.
  7. James picks a version number. No auto-bake button — keeps the HTML portable and avoids needing a server endpoint just for the picker.
  8. Bake the chosen jpg with PIL:
    from PIL import Image
    img = Image.open("~/apps/image-create/icon/<repo-name>/<repo-name>-vN-gemini-nano-banana-pro.jpg").convert("RGBA")
    # crop square if needed (Nano Banana renders are already 1024x1024)
    for size in (16, 48, 128):
    img.resize((size, size), Image.LANCZOS).save(f"~/apps/<repo-name>/extension/icons/icon-{size}.png", "PNG", optimize=True)
  9. Bump build and commit:
    Terminal window
    bash bump-build.sh "Bake v<N> icon (<concept>) into 16/48/128"
    git commit -am "Build N: Bake v<N> icon + commit icon-review HTML for posterity"
    Keep icon-review.html and icon-review-images/ in the repo so future-you can see the alternatives that were rejected.
  10. Reload in Chrome. The toolbar icon updates immediately on extension reload.

dictionary-extension Build 7 (2026-05-28) — six concepts fired, five succeeded, James picked v2 (sculpted gold serif “D” on navy tile) for its readability at 16px. v6 (quill writing) failed mid-run with an AI Studio internal error; not worth re-firing because the other five gave him a real pick. The review HTML at ~/apps/dictionary-extension/icon-review.html is committed and viewable.

  • Throwaway internal extensions that will never see anyone else’s screen. Ship the blue monogram, move on.
  • Heavy text icons (e.g., a “JS” badge for a JavaScript tool). Nano Banana garbles text. Use generate-image-openai-browser.py --style icon instead — ChatGPT’s gpt-image-2 renders text sharply.
  • Brand-identity tied icons that need to match an existing logo verbatim. Either reuse the existing PNG or commission the designer.

Follow in order. Do not skip steps.

  1. Pick the repo name. fb-* for Facebook-specific extensions. Otherwise a short descriptive name (read-aloud-extension, tab-muter, etc.).
  2. Create the folder structure:
    Terminal window
    mkdir -p ~/apps/<repo-name>/extension/icons
    cd ~/apps/<repo-name>
  3. Initialize build.txt at repo root: echo 1 > build.txt
  4. Copy bump-build.sh from the nearest existing extension (read-aloud-extension or any fb-* repo). Make it executable: chmod +x bump-build.sh.
  5. Write extension/manifest.json:
    {
    "manifest_version": 3,
    "name": "Extension Name",
    "version": "1.0.1",
    "description": "Build 1 - Initial skeleton",
    "permissions": ["..."],
    "host_permissions": ["..."],
    "background": { "service_worker": "background.js", "type": "module" },
    "action": { "default_popup": "popup.html" },
    "content_scripts": [
    { "matches": ["..."], "js": ["content.js"], "run_at": "document_idle" }
    ]
    }
    Paths are relative to extension/. Do NOT prefix with src/.
  6. Write the code under extension/: background.js, popup.html/js/css, content.js, plus offscreen.html/js if you need audio playback (see MV3 audio rule below).
  7. Generate icons at 16, 48, and 128 pixels under extension/icons/. Default route: follow the Icon Generation flow above — six concepts via Nano Banana on Remote Mac, review on the MBP, bake the chosen one. A blue-letter placeholder is acceptable only for throwaway internal tools. Missing icons ship with a default puzzle piece, which looks unfinished.
  8. Copy the CLAUDE.md template from ~/apps/cc/templates/CLAUDE.md. Fill in the placeholders, document the folder layout, note any extension-specific conventions.
  9. Set up .env / .env.example per the Env Files SOP. Even if the extension has no secrets yet, commit a stub .env.example so future config has a home. Extensions inject values at build time (via bump-build.sh writing extension/config.js) since the browser has no process.env. Gitignore .env and extension/config.js.
  10. Initialize git:
    Terminal window
    git init -b main
    git add -A
    git commit -m "Build 1: Initial skeleton"
  11. Create a PRIVATE GitHub repo:
    Terminal window
    gh repo create <repo-name> --private --source=. --remote=origin --push
  12. Load in Chrome to smoke-test:
    • chrome://extensions
    • Toggle “Developer mode” on
    • “Load unpacked” → click apps<repo-name>extension
    • Pin it, click it, confirm the popup renders
  13. Run bash ~/apps/cc/post-push-sync.sh so the Mac Studio and VPS get the repo.
  14. Add the extension to the TMS Internal capabilities sidebar at tms-internal/astro.config.mjs under “FB Chrome Extensions” (for fb-*) or a new category for standalone extensions.

Every extension ships with programmatic logging from Build 1. Console.log in DevTools is invisible to Claude — which means every debug session becomes “can you open DevTools, click this, click that, paste the output.” That violates the “do the legwork” principle. It is also just slow.

extension logger.js ──fetch──▶ localhost:9876
~/apps/cc/chrome-log-receiver.py (launchd, always on)
~/apps/cc/logs/<extension-name>.log
Claude reads via tail / Read
  • Receiver: ~/apps/cc/chrome-log-receiver.py runs under launchd (~/Library/LaunchAgents/com.cc.chrome-log-receiver.plist). Always on. Accepts POST /log/<source> with JSON body {level, message, context, timestamp}. Writes to ~/apps/cc/logs/<source>.log.
  • Logger module: ~/apps/cc/templates/chrome-extension-logger.js is the canonical template. Copy into extension/logger.js in each new extension. Change the hardcoded source name at the top.
  • Integration: Every entry-point file (background.js, offscreen.js, popup.js, plus any shared modules like edge-tts.js) imports createLogger and gets a per-context logger. log.info / log.warn / log.error replace every console.* call.

Logger fetches http://127.0.0.1:9876. The extension needs host access for that:

  • If host_permissions already includes <all_urls>, you are covered.
  • Otherwise add "http://127.0.0.1/*" to host_permissions.
// extension/logger.js (one per repo, hardcode the source name)
const ENDPOINT = "http://127.0.0.1:9876/log/your-extension-name";
// ... rest copied from ~/apps/cc/templates/chrome-extension-logger.js
// extension/background.js (and every other entry point)
import { createLogger } from "./logger.js";
const log = createLogger("background"); // context name
log.info("service worker loaded");

Never write a user-facing error like “check the service worker console for details” or “see DevTools.” If the error is unclear, the fix is more logging to the file, not a breadcrumb trail for the human to follow.

Terminal window
tail -F ~/apps/cc/logs/<extension-name>.log

Claude should do this itself during any debug session, not ask the human to open DevTools.

If the receiver ever dies (it shouldn’t — KeepAlive is true):

Terminal window
launchctl unload ~/Library/LaunchAgents/com.cc.chrome-log-receiver.plist
launchctl load -w ~/Library/LaunchAgents/com.cc.chrome-log-receiver.plist
curl -s http://127.0.0.1:9876/health # should print "ok"

Every extension popup shows the current build number and the build summary. Not buried in Settings — visible the moment the popup opens, near the title.

This matters because:

  1. I forget which build is loaded. Chrome caches aggressively. I reload the extension, but am I actually looking at the new code? The build tag answers that in one glance.
  2. The description is already canonical (bump-build.sh writes Build X - summary into manifest.json). No duplication — just render it.
  3. Bug reports improve. “It’s broken on Build 14” is useful. “It’s broken” is not.

Near the top of popup.html:

<div class="header">
<div class="title">Extension Name</div>
<div class="build-tag" id="buildTag">Build 0</div>
<div class="build-summary" id="buildSummary"></div>
</div>
(function setBuildTag() {
const manifest = chrome.runtime.getManifest();
const tag = document.getElementById("buildTag");
const summary = document.getElementById("buildSummary");
if (!manifest.description) return;
const match = manifest.description.match(/^Build (\d+)\s*-\s*(.+)$/);
if (match && tag) tag.textContent = `Build ${match[1]}`;
if (match && summary) summary.textContent = match[2];
})();

This parses the Build X - summary format that bump-build.sh writes. No additional file needed — chrome.runtime.getManifest() is synchronous and free.

.build-tag { font-size: 10px; font-weight: 600; color: #4a6cf7; text-transform: uppercase; letter-spacing: 0.5px; }
.build-summary { font-size: 11px; color: #888; line-height: 1.3; }

Precedent: fb-group-monitor renders a “Build X” tag in its popup header. read-aloud-extension Build 5 added both the tag and the summary after James pointed out the summary was missing.

Side-Panel Extensions — Build Number in the Header

Section titled “Side-Panel Extensions — Build Number in the Header”

A side-panel extension has no popup, so the popup build tag above does not apply. The equivalent is the Chrome side-panel header, which renders the manifest name. You cannot inject programmatic content into that header — but the name is a hard-coded label, and bump-build.sh owns it.

So for side-panel extensions, bump-build.sh also rewrites name to <Base Name> · <build> — e.g. Claude Code · 35. The header then shows which build is actually loaded. This matters because a panel that talks to a local server can show the server’s build in its own UI while the extension itself is stale — the manifest name reflects the extension, not the server.

One encoding gotcha: write the manifest with json.dump(..., ensure_ascii=False) so the · stays literal. Escaped to ·, a regex backstop that strips the old suffix will miss it and double-stamp (Claude Code · 35 · 35).

Precedent: claude-code-chrome-ext Build 35.

Unpacked dev extensions need to be reloaded after every code change. The default path — chrome://extensions → find the card → click the per-extension icon — is three clicks of friction. Every extension I build collapses that to one or two.

There are two patterns. Pick based on whether the reload should be instant (no new tab) or navigate to the extensions page (so I can also poke at toggles, errors, service-worker inspector).

A button in the popup/panel header. Click calls chrome.runtime.reload(). The extension restarts with the new code from disk. No tab opened, no navigation.

<button id="reloadBtn" class="icon-btn" title="Reload extension"></button>
document.getElementById("reloadBtn").addEventListener("click", () => {
chrome.runtime.reload();
});

Use this when I just want the new build active and do not need to see the extension card.

A button that opens chrome://extensions/?id=<my-id>. Chrome lands me on the extensions page with the card highlighted; the per-extension reload icon is right there, plus error logs and the service-worker inspector link.

<button id="extPageBtn" class="icon-btn" title="Open extension page (for reload)"></button>
document.getElementById("extPageBtn").addEventListener("click", () => {
const extId = chrome.runtime.id;
chrome.tabs.create({ url: `chrome://extensions/?id=${extId}` });
});

Use this when I want context — the extensions page also shows enable toggle, errors badge, and the service-worker link if I need to inspect why something failed.

Precedent: claude-code-chrome-ext ships Pattern B at extension/panel.html:13 and extension/panel.js:207.

  • Pattern A for extensions I iterate on heavily during a session — one click, get back to work.
  • Pattern B for extensions where errors or service-worker state matter — I want eyes on the card.
  • Ship both in larger panels by adding two buttons ( instant, ⟶⚙ page) side by side.

Never ask James to open DevTools, Chrome’s service worker inspector, or paste console output. If the information is not in ~/apps/cc/logs/, the extension does not have enough logging yet — fix the extension, not the human.

This rule exists because of read-aloud-extension Build 3, where a fresh commit shipped an error message reading “Edge TTS WebSocket error (check service worker console for details)” — the exact behavior this SOP forbids — written minutes after James said he never wanted to hunt for logs again.

  • Service workers cannot play audio. If the extension needs audio playback (TTS, sound effects), create an offscreen document with reason AUDIO_PLAYBACK and put the <audio> element there. The service worker then messages the offscreen doc to play.
  • Service workers die. They suspend after ~30 seconds of inactivity. Never store state in module-level variables expecting it to survive. Use chrome.storage.local or chrome.storage.session.
  • chrome.runtime.sendMessage broadcasts to all listeners. If the background sends a message intended for the offscreen doc, the popup and background itself will also receive it. Filter by msg.target or msg.type at the top of every listener.
  • Content scripts cannot use ES modules natively. Stick to IIFE or classic scripts. Only the background service worker (with "type": "module" in manifest) and popup/offscreen docs (with <script type="module">) can use ESM imports.
  • async message handlers need return true so Chrome keeps the port open for sendResponse.
  • Every real code change that affects behavior. Run bash bump-build.sh "what changed" as the last step before git add.
  • Do NOT bump for test-only changes, log cleanup, or comment-only edits.
  • Do NOT bump for README/CLAUDE.md edits unless they accompany a code change. (Docs-only commits stay on whatever build the code is on.)

James’s standard path, same for every repo:

  1. chrome://extensions
  2. Toggle Developer mode on (top right)
  3. Click Load unpacked
  4. Navigate: apps → <repo-name>extension
  5. Confirm the extension appears with the right icon and description
  6. Pin it to the toolbar

The “inner folder” step is not optional. Pointing Chrome at the repo root ships README.md, build.txt, .git/, and everything else as part of the extension package, which will cause weird behavior and failed publishes.