Building extensions
Chrome's format, Caspian's runtime. Everything below is a complete, runnable example: put the files in a folder, Load unpacked on caspian:extensions, and it runs.
The manifest
Manifest V3 is the format to write; V2 still loads. The keys Caspian reads: name, version, description, icons, action (or browser_action), background, content_scripts, options_ui / options_page, permissions, host_permissions, commands, declarative_net_request, default_locale, chrome_url_overrides.newtab.
{
"manifest_version": 3,
"name": "Word Count",
"version": "1.0.0",
"description": "Counts the words on the page you are reading.",
"icons": { "48": "icon.png", "128": "icon.png" },
"action": {
"default_title": "Word Count",
"default_popup": "popup.html",
"default_icon": { "16": "icon.png", "32": "icon.png" }
},
"background": { "service_worker": "bg.js" },
"content_scripts": [
{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }
],
"permissions": ["storage", "activeTab", "contextMenus"],
"host_permissions": ["<all_urls>"],
"commands": {
"count-now": { "suggested_key": { "default": "Ctrl+Shift+U" }, "description": "Count words on this page" }
},
"options_page": "options.html"
}Content scripts
Content scripts run in an isolated world on every page that matches: they see the page's DOM, the page's own scripts don't see them, and they get a chrome object with runtime, storage and i18n. run_at takes document_start, document_end or document_idle; all_frames and match_about_blank behave as in Chrome."world": "MAIN" puts a script into the page's own world, without chrome.
// Count on load and whenever the background asks.
const count = () => (document.body?.innerText.match(/\S+/g) || []).length;
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "count") sendResponse({ words: count(), title: document.title });
});
chrome.runtime.sendMessage({ type: "page-loaded", words: count() });The background and messaging
MV3's service_worker runs as a hidden page in Caspian and never idles, so chrome.alarms, timers and in-memory state are fine. importScripts, clients and skipWaiting exist. Messages work the three Chrome ways: sendResponse synchronously, return true and answer later, or return a promise.
chrome.runtime.onInstalled.addListener(({ reason }) => {
chrome.storage.local.set({ installedAt: Date.now(), reason });
chrome.contextMenus.create({ id: "count", title: "Count words on this page", contexts: ["page", "selection"] });
});
// A page loaded: remember its count and show it on the badge.
chrome.runtime.onMessage.addListener((msg, sender) => {
if (msg.type === "page-loaded" && sender.tab) {
chrome.action.setBadgeText({ text: String(msg.words) });
chrome.action.setBadgeBackgroundColor({ color: "#4f8cff" });
}
});
// Ask the current tab's content script, from a command or the context menu.
async function countActive() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) return;
const r = await chrome.tabs.sendMessage(tab.id, { type: "count" });
chrome.notifications.create({ type: "basic", iconUrl: "icon.png", title: r.title, message: r.words + " words" });
}
chrome.commands.onCommand.addListener((name) => { if (name === "count-now") countActive(); });
chrome.contextMenus.onClicked.addListener((info) => { if (info.menuItemId === "count") countActive(); });
// Ports work too, for long conversations.
chrome.runtime.onConnect.addListener((port) => {
port.onMessage.addListener((m) => port.postMessage({ echo: m }));
});The popup and options page
action.default_popup opens in a card under the toolbar button, sized to your document (up to 800×600). Give body a width; keep heights natural. window.close() closes it. The options page opens in a tab.
<!doctype html>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<style>
:root { color-scheme: light dark; }
body { width: 280px; margin: 0; padding: 16px; font: 14px system-ui;
color: light-dark(#1f1f1f, #f4f4f6); background: light-dark(#fff, #1c1b22); }
</style>
<body>
<h3 style="margin: 0 0 8px">Word Count</h3>
<div id="out">Counting…</div>
<button id="options">Options</button>
<script src="popup.js"></script>
</body>Declare color-scheme: light dark and pick colours with light-dark() (or aprefers-color-scheme media query): the popup then follows the user's theme in Caspian and in Chrome instead of turning up white on a dark browser.
(async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const r = await chrome.tabs.sendMessage(tab.id, { type: "count" });
document.getElementById("out").textContent = r.words + " words on " + new URL(tab.url).host;
})();
document.getElementById("options").onclick = () => chrome.runtime.openOptionsPage();Storage
chrome.storage.local, sync and session all work; sync is a second local area (there is no account to sync through), session is in memory for the run. onChanged fires in every context of the extension. Values are JSON.
await chrome.storage.local.set({ theme: "dark", limit: 5 });
const { theme, limit = 3 } = await chrome.storage.local.get({ theme: "light", limit: 3 });
chrome.storage.onChanged.addListener((changes, area) => console.log(area, changes));Network rules
declarativeNetRequest runs inside Caspian's request pipeline, before its own blocker: static rulesets from the manifest, dynamic rules (persisted) and session rules. Actions: block, allow, allowAllRequests, redirect (url, extensionPath, regexSubstitution, transform), upgradeScheme, modifyHeaders (request headers). Conditions: urlFilter, regexFilter, resourceTypes, domainType, initiator/request domains.
[
{ "id": 1, "priority": 1, "action": { "type": "block" },
"condition": { "urlFilter": "||tracker.example^", "resourceTypes": ["script", "image", "xmlhttprequest"] } },
{ "id": 2, "priority": 2, "action": { "type": "redirect", "redirect": { "extensionPath": "/empty.js" } },
"condition": { "urlFilter": "||ads.example/*.js", "resourceTypes": ["script"] } }
]"permissions": ["declarativeNetRequest"],
"declarative_net_request": { "rule_resources": [ { "id": "base", "enabled": true, "path": "rules.json" } ] }webRequest is observation only: onBeforeRequest fires with Chrome's details, but the request is already on its way, so blocking listeners can't cancel it. Use declarativeNetRequest to block.
Caspian's own API
chrome.caspian exists only in Caspian and is how an extension talks to the browser itself. It is small today and grows with what extensions ask for.
chrome.caspian.status("Saved to your list", 2500); // the status line at the bottom of the window
const theme = await chrome.caspian.theme(); // { dark, bg, text, accent, surface, border }
const version = chrome.caspian.version(); // "0.1.6"Localisation
_locales/<code>/messages.json with default_locale in the manifest;__MSG_key__ placeholders in the manifest resolve, and chrome.i18n.getMessage supports placeholders and substitutions.
Testing and debugging
caspian:extensions shows each extension's recent background errors (the red "N errors" tag) and offers Reload. Start Caspian with QTWEBENGINE_REMOTE_DEBUGGING=9222 and open http://localhost:9222 in another browser: background pages appear as caspian-ext://<id>/_generated_background_page.html, popups and options pages under their own paths, with the full DevTools.
What is different from Chrome
- The scheme is
caspian-ext://. Usechrome.runtime.getURL(), never a hard-coded prefix. Servers see requests from your pages withOrigin: chrome-extension://<id>, as they would from Chrome. - Ids are Chrome's (32 letters a–p): the store's id for store installs, derived from the path for unpacked.
- The background never sleeps; there is no service-worker termination to design around.
- Content scripts of all extensions share one isolated world; keep globals inside a closure.
- No native messaging, DevTools panels, side panel UI, omnibox keywords,
userScripts, blockingwebRequest, or response-header rules. Extensions don't run in private windows. tabs.captureVisibleTabcaptures the current tab; per-tab badges apply to the button as a whole.