SOPs
Chrome Extension — Repo Layout, Build System, Manifest Rules
The Rule
Section titled “The Rule”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:
- Load-unpacked UX. James loads extensions by clicking
appsthen the repo folder then theextensionfolder. Same path every time, no hunting. - No accidental shipping. Putting
README.md,.git/,CLAUDE.md, andbuild.txtat repo root keeps them out of the Chrome package. Only files underextension/ship. - Deterministic manifest.
bump-build.shenforces theversionformat (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/, andCLAUDE.mdas part of the extension package. - There was no
bump-build.sh, so the manifestversionanddescriptiondrifted frombuild.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.
Folder Layout
Section titled “Folder Layout”<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 PNGsThe Manifest Rules
Section titled “The Manifest Rules”manifest_version: 3. Always. MV2 is deprecated.versionis1.0.Xwhere X is the current build number.bump-build.shwrites this for you.descriptionisBuild X - short summary, capped at 132 characters. Chrome silently rejects longer values on install.- Paths in the manifest are relative to
extension/, not the repo root. Nosrc/prefix. host_permissionsis the narrowest match that still works.<all_urls>is fine for a general-purpose reader likeread-aloud-extension, but an FB-only extension should listhttps://www.facebook.com/*.
The Build Script — bump-build.sh
Section titled “The Build Script — bump-build.sh”Every repo has its own copy of this file at the root. It does three things atomically:
- Increments
build.txt - Rewrites
extension/manifest.jsonversionto1.0.{new-build} - Rewrites
extension/manifest.jsondescriptiontoBuild {new-build} - {your message}
It refuses to run if the resulting description would be over 132 chars. That is Chrome’s hard limit.
Canonical script
Section titled “Canonical script”#!/bin/bashset -eREPO_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; fiDESC="$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; fiecho "$NEXT" > "$BUILD_FILE"python3 -c "import jsonwith 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)"bash bump-build.sh "Add dark mode toggle"Then commit with the matching message:
git commit -m "Build 15: Add dark mode toggle"Optional: pre-commit backstop
Section titled “Optional: pre-commit backstop”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.
Why this flow
Section titled “Why this flow”- 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.
- Browser backend on Remote Mac, free.
generate-image-gemini-browser.pydrives Chrome on Remote Mac via SSH, no per-image cost on the existing Google AI Pro plan. - Pick on the MBP. Review HTML lives at the extension repo’s root (
icon-review.html), images alongside inicon-review-images/. James clicks a tile to mark his pick, then tells Claude the version number. - Bake server-side. Once chosen,
PILresizes the 1024×1024 jpg to 16/48/128 PNGs withLANCZOS, drops intoextension/icons/, andbump-build.shships it.
Step-by-step
Section titled “Step-by-step”- Write six concept prompts at
/tmp/<repo-name>-icon-prompts/v1-<concept>.txtthroughv6-<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
- 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} - Fire the parallel run:
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.
Terminal window cd ~/apps/image-create && python3 generate-image-gemini-browser.py --manifest /tmp/<repo-name>-icon-manifest.json - Stage for review — copy outputs from
~/apps/image-create/icon/<repo-name>/into~/apps/<repo-name>/icon-review-images/. - 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. - 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.htmlopen-html.shis SSH-aware and forwards the open to the client over SSH. - James picks a version number. No auto-bake button — keeps the HTML portable and avoids needing a server endpoint just for the picker.
- Bake the chosen jpg with PIL:
from PIL import Imageimg = 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)
- Bump build and commit:
Keep
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"icon-review.htmlandicon-review-images/in the repo so future-you can see the alternatives that were rejected. - Reload in Chrome. The toolbar icon updates immediately on extension reload.
Precedent
Section titled “Precedent”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.
When NOT to use this flow
Section titled “When NOT to use this flow”- 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 iconinstead — 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.
New Chrome Extension — Setup Checklist
Section titled “New Chrome Extension — Setup Checklist”Follow in order. Do not skip steps.
- Pick the repo name.
fb-*for Facebook-specific extensions. Otherwise a short descriptive name (read-aloud-extension,tab-muter, etc.). - Create the folder structure:
Terminal window mkdir -p ~/apps/<repo-name>/extension/iconscd ~/apps/<repo-name> - Initialize
build.txtat repo root:echo 1 > build.txt - Copy
bump-build.shfrom the nearest existing extension (read-aloud-extensionor anyfb-*repo). Make it executable:chmod +x bump-build.sh. - Write
extension/manifest.json:Paths are relative to{"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" }]}extension/. Do NOT prefix withsrc/. - Write the code under
extension/:background.js,popup.html/js/css,content.js, plusoffscreen.html/jsif you need audio playback (see MV3 audio rule below). - 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. - Copy the CLAUDE.md template from
~/apps/cc/templates/CLAUDE.md. Fill in the placeholders, document the folder layout, note any extension-specific conventions. - Set up
.env/.env.exampleper the Env Files SOP. Even if the extension has no secrets yet, commit a stub.env.exampleso future config has a home. Extensions inject values at build time (viabump-build.shwritingextension/config.js) since the browser has noprocess.env. Gitignore.envandextension/config.js. - Initialize git:
Terminal window git init -b maingit add -Agit commit -m "Build 1: Initial skeleton" - Create a PRIVATE GitHub repo:
Terminal window gh repo create <repo-name> --private --source=. --remote=origin --push - 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
- Run
bash ~/apps/cc/post-push-sync.shso the Mac Studio and VPS get the repo. - Add the extension to the TMS Internal capabilities sidebar at
tms-internal/astro.config.mjsunder “FB Chrome Extensions” (for fb-*) or a new category for standalone extensions.
Programmatic Logging — Non-Negotiable
Section titled “Programmatic Logging — Non-Negotiable”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.
The Architecture
Section titled “The Architecture”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.pyruns 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.jsis the canonical template. Copy intoextension/logger.jsin 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 likeedge-tts.js) importscreateLoggerand gets a per-context logger.log.info/log.warn/log.errorreplace everyconsole.*call.
Required manifest permission
Section titled “Required manifest permission”Logger fetches http://127.0.0.1:9876. The extension needs host access for that:
- If
host_permissionsalready includes<all_urls>, you are covered. - Otherwise add
"http://127.0.0.1/*"tohost_permissions.
Required code pattern
Section titled “Required code pattern”// 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 namelog.info("service worker loaded");Error messages must not point at DevTools
Section titled “Error messages must not point at DevTools”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.
Reading the logs
Section titled “Reading the logs”tail -F ~/apps/cc/logs/<extension-name>.logClaude should do this itself during any debug session, not ask the human to open DevTools.
Restart / reload
Section titled “Restart / reload”If the receiver ever dies (it shouldn’t — KeepAlive is true):
launchctl unload ~/Library/LaunchAgents/com.cc.chrome-log-receiver.plistlaunchctl load -w ~/Library/LaunchAgents/com.cc.chrome-log-receiver.plistcurl -s http://127.0.0.1:9876/health # should print "ok"Build Number Must Be Visible in the Popup
Section titled “Build Number Must Be Visible in the Popup”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:
- 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.
- The description is already canonical (
bump-build.shwritesBuild X - summaryintomanifest.json). No duplication — just render it. - Bug reports improve. “It’s broken on Build 14” is useful. “It’s broken” is not.
Required HTML
Section titled “Required HTML”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>Required JS (top of popup.js)
Section titled “Required JS (top of popup.js)”(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.
Suggested CSS
Section titled “Suggested CSS”.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.
Quick Reload Button in the Header
Section titled “Quick Reload Button in the Header”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).
Pattern A — One-click instant reload
Section titled “Pattern A — One-click instant reload”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.
Pattern B — Two-click open-the-card
Section titled “Pattern B — Two-click open-the-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.
When to pick which
Section titled “When to pick which”- 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.
The “Never Ask” Rule
Section titled “The “Never Ask” Rule”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.
MV3 Rules to Remember
Section titled “MV3 Rules to Remember”- Service workers cannot play audio. If the extension needs audio playback (TTS, sound effects), create an offscreen document with reason
AUDIO_PLAYBACKand 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.localorchrome.storage.session. chrome.runtime.sendMessagebroadcasts to all listeners. If the background sends a message intended for the offscreen doc, the popup and background itself will also receive it. Filter bymsg.targetormsg.typeat 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. asyncmessage handlers needreturn trueso Chrome keeps the port open forsendResponse.
When to Bump the Build
Section titled “When to Bump the Build”- Every real code change that affects behavior. Run
bash bump-build.sh "what changed"as the last step beforegit 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.)
How to Load the Extension in Chrome
Section titled “How to Load the Extension in Chrome”James’s standard path, same for every repo:
chrome://extensions- Toggle Developer mode on (top right)
- Click Load unpacked
- Navigate: apps →
<repo-name>→extension - Confirm the extension appears with the right icon and description
- 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.