How to build a Pomodoro Timer App with JavaScript
Learn how to build a Pomodoro clock application in the browser with JavaScript
Chrome extensions let you add new features to your browser, from password managers to privacy tools and productivity helpers. If you’ve ever wondered what it takes to build one yourself, this tutorial will guide you through the process step by step and show that creating your own extension is far more approachable than it might seem.
Chrome extensions let you customize the browser in ways that go far beyond simple settings or themes. They can block ads, automate repetitive tasks, enhance your favorite sites, or reshape the browser interface entirely.
If you’ve ever wished a website or the browser itself behaved just a bit differently, chances are an extension already exists to do exactly that.
Learning how to build extensions opens up a space where familiar web technologies gain access to native browser capabilities. You can tap into APIs that ordinary web pages cannot use, allowing your code to run in the background, react to browser events, store data, and integrate deeply into how people work online.
And now, with built-in AI models available directly in Chrome, extensions can go even further. You can generate text on the fly, summarize content, assist users contextually, or build entirely new experiences that adapt to the user’s intent.
So whether you’re solving a personal frustration or just experimenting with a new distribution channel for your ideas, extensions offer an approachable yet powerful platform. And if you already know HTML, CSS, and JavaScript, you’re already 95% of the way there.
In this tutorial, you’ll learn the rest of the fundamentals by building a small but fully functional Chrome extension from scratch.
To understand the various Chrome extensions concepts, you’ll build a new tab extension that replaces the default new tab page with a random image from Unsplash.
If you’ve ever used popular new tab extensions like Tabliss, or Momentum, , you already know how transformative this simple UI tweak can be. These extensions turn an empty tab into something visually delightful, functional, or even calming.
Your own version will:
By the end of this guide, you’ll understand how the different parts of an extension fit together: the manifest, service worker, new tab override, options page, popup, and storage.
You’ll also see how to use the built-in AI APIs to add a new dimension to what an extension can offer while still keeping everything running locally and securely on the user’s device.
If you have experience building basic webages, you have everything you need. But make sure you also have:
To keep the focus on learning how extensions work rather than writing boilerplate HTML and CSS, this tutorial includes a small set of starter files. They contain the basic layout for the new tab page, the options screen, and the extension’s folder structure.
You can download them from GitHub:
git clone https://github.com/freshman-tech/freshtab-starter-files.git
After cloning the repository, move into the project directory:
cd freshtab-starter-files
You can browse the files using your editor or run the tree command (if you
have it installed) to get a quick overview of the structure.
tree
├── demo.png
├── icons
│ ├── 128.png
│ ├── 16.png
│ ├── 32.png
│ ├── 48.png
│ └── 64.png
├── index.html
├── js
│ ├── background.js
│ ├── constants.js
│ ├── index.js
│ ├── options.js
│ └── popup.js
├── LICENCE
├── manifest.json
├── options.html
├── package.json
├── popup.html
└── README.md
This gives you a fully prepared workspace so you can focus on the manifest, extension APIs, and the logic that brings everything together.
A Chrome extension is simply a collection of files—HTML, CSS, JavaScript, plus a manifest—that work together to add new capabilities to the browser. Before writing any code, it helps to understand the role each file plays in the project.
This manifest.json file is
the blueprint of your extension.
It’s a single file that tells Chrome what your extension is, what it does, and
the permissions it needs, and other metadata.
The most important thing to know for now is that we are using Manifest V3, the modern standard for all new extensions.
A service worker is an event-driven script that wakes up to handle an event (like the extension being installed or a message from another script), does its work, and then terminates.
In this project, the js/background.js file will act as a service worker, and
react to browser events and messages to perform various actions in the
background.
When a user clicks your extension’s icon in the toolbar, Chrome can display a
small popup. This popup is just a regular HTML page (popup.html) with its own
JavaScript and styling. Extensions often use popups to provide quick controls or
shortcuts.
Extensions can replace certain built in browser pages, such as the new tab page,
history page, or bookmarks page. You can override one of these at a time by
pointing Chrome to an HTML file in the manifest. In this guide, index.html
replaces the default new tab page.
Every extension needs at least one icon so Chrome can display it in the toolbar,
extensions page, and Chrome Web Store. The icons used in this tutorial live in
the icons folder.
Content scripts are JavaScript files that run directly inside web pages. They can read or modify the page’s DOM, making them ideal for features like password managers, ad blockers, and page enhancements.
While this project doesn’t use content scripts, they’re an important part of many extensions and good to be aware of.
Content scripts are JavaScript files that run in the context of web pages loaded in your browser. They have full access to the page’s DOM and are used for things like reading or modifying web content (e.g., ad blockers or password managers).
We don’t need a content script for this particular project, but they’re a fundamental part of many extensions.
Let’s start building the Chrome extension by defining the required fields in the
manifest.json file. Open up this file in your text editor and update it with
the following code:
{
"manifest_version": 3,
"name": "freshtab",
"version": "1.0.0",
"description": "Experience a beautiful photo from Unsplash every time you open a new tab.",
"icons": {
"16": "icons/16.png",
"32": "icons/32.png",
"48": "icons/48.png",
"64": "icons/64.png",
"128": "icons/128.png"
},
"chrome_url_overrides": {
"newtab": "index.html"
},
"action": {
"default_popup": "popup.html"
},
"options_page": "options.html",
"permissions": ["storage"],
"host_permissions": ["https://api.unsplash.com/"],
"background": {
"service_worker": "js/background.js",
"type": "module"
}
}
Here’s a breakdown of each field in the manifest file:
manifest_version: Specifies the version of the manifest.json used by this
extension. Currently, this must always be 3 as
v2 is now deprecated.name: The extension name.version: The extension version.description: Describes what the extension does.icons: Specifies icons for your extension in different sizes which will
appear throughout the browser interface to identify the extension.chrome_url_overrides: Used to provide a custom replacement for default
browser pages (like new tab, history, or bookmarks). In this case, the new tab
page is being replaced with the index.html file.action: Used to define the appearance and behavior for the extension’s icon
adds in the browser toolbar.options_page: Specifies the path to the options page for your extension.permissions: Defines the
permissions
required by the extension. We need the storage permission alone to access
the Chrome storage API.host_permissions: If your extension interacts with an web page or external
API, you must list the specific URLs here.background: Used to register the extension’s service worker, which acts as
an event handler for background tasks. The type: module line is necessary
for the browser to recognize it as an ES module script.Now that you understand the structure of a Chrome extension, it’s time to bring
it to life by creating the manifest.json file. This is the central
configuration file that Chrome reads first, and without it the browser won’t
recognize your project as an extension.
Open the manifest.json file in the starter project and update it with the
following:
{
"manifest_version": 3,
"name": "freshtab",
"version": "1.0.0",
"description": "Experience a beautiful photo from Unsplash every time you open a new tab.",
"icons": {
"16": "icons/16.png",
"32": "icons/32.png",
"48": "icons/48.png",
"64": "icons/64.png",
"128": "icons/128.png"
},
"chrome_url_overrides": {
"newtab": "index.html"
},
"action": {
"default_popup": "popup.html"
},
"options_page": "options.html",
"permissions": ["storage", "unlimitedStorage"],
"host_permissions": ["https://api.unsplash.com/"],
"background": {
"service_worker": "js/background.js",
"type": "module"
}
}
Here’s a quick breakdown of what each part does:
3 for all new extensions.index.html.With this manifest in place, Chrome now knows how to load and run your extension. Next, you’ll prompt the user for an Unsplash access key so the extension can fetch images.
Your extension will fetch photos from the Unsplash API, but it should never include your own access key in the source code. Anyone could extract it, and all users would share your API quota. A better approach is to have each user supply their own key through the extension’s settings page.
The core functionality for this extension is fetching and displaying images from Unsplash in each new tab.
The first step is to fetch a random image from Unsplash. An API endpoint exists for this purpose:
https://api.unsplash.com/photos/random
This endpoint accepts a number of
query parameters to narrow
the pool of photos for selection. For our project, we’ll use the orientation
parameter to limit the results to landscape images only:
https://api.unsplash.com/photos/random?orientation=landscape
The /photos/random endpoint requires
authentication via
the HTTP Authorization header. This is done by setting the Authorization
header to Client-ID <ACCESS_KEY> where <ACCESS_KEY> is a valid key from
Unsplash.
Follow the instructions on this page to create a free Unsplash account, and register a new application. Once your app is created, copy the Unsplash access key string in the application settings page.
This brings us to a crucial part of building browser extensions responsibly: you must not hardcode your own developer access key into the extension’s code. This is insecure (anyone could steal it) and not scalable, as all users would share a single API quota.
The correct, professional approach is to have the user provide their own API key. To make this seamless, you can open the options page automatically when the extension is first installed. This gives users a clear prompt to enter their access key before the extension tries to load any images.
Add the following to your js/background.js file:
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
chrome.runtime.openOptionsPage();
}
});
This listener runs only when the extension is installed for the first time. Chrome will open the options page in a new tab, letting you add your Unsplash access key right away.
Next, wire up the logic that loads those saved values and stores new ones. Open
js/options.js and add:
async function saveOptions() {
const key = document.getElementById('unsplashKey').value;
const collections = document.getElementById('collections').value;
await chrome.storage.local.set({
unsplashAccessKey: key,
collections,
});
const status = document.getElementById('status');
status.textContent = 'Options saved.';
setTimeout(() => {
status.textContent = '';
}, 1500);
}
async function restoreOptions() {
const result = await chrome.storage.local.get([
'unsplashAccessKey',
'collections',
]);
document.getElementById('unsplashKey').value = result.unsplashAccessKey || '';
document.getElementById('collections').value = result.collections || '';
}
document.addEventListener('DOMContentLoaded', restoreOptions);
document.getElementById('save').addEventListener('click', saveOptions);
This script does two things:
chrome.storage.local when users click the Save
Settings button.We’re using chrome.storage.local to save the API key and collection IDs to the
local machine only. If you need to sync certain extension settings across all
Chrome browsers where the user is logged in, you may use the
chrome.storage.sync
API instead.
The trade-off is a much smaller storage quota (around 100 KB), but that’s usually enough for most settings objects.
Once these pieces are in place, load your extension in Chrome by typing
chrome://extensions in the address bar and select Load unpacked after
enabling Developer mode
Navigate to the root of your project directory (where the manifest.json file
lives) and select it. When the extension installs, the options page will open
automatically and you can enter your Unsplash key.
You can confirm this by opening the Chrome DevTools with F12, then go to the
Application tab and find the Extension storage > Local entry. You should
see the empty collections field and the unsplashAccessKey key that you just
added:
With the access key safely stored, the extension is ready to start fetching images in the background.
When you open a new browser tab, it should already be replaced by the one
defined in your extension manifest (index.html). This page is currently blank
as shown below:
You may need to turn off the Footer in recent versions of Chrome. Click Customize Chrome or go to More tools > Customize Chrome in the browser menu to fond the relevant option:
With the settings page wired up and your new tab page ready to render content, the next step is to actually load an image. That work lives in the service worker, which will:
chrome.storage.Update your js/background.js file to look like this:
// js/background.js
import { CACHE_NAME, IMAGE_KEY, METADATA_KEY } from './constants.js';
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
chrome.runtime.openOptionsPage();
}
// Always fetch a new image on install or update
fetchAndCacheImage();
});
async function fetchAndCacheImage() {
try {
const { unsplashAccessKey } = await chrome.storage.local.get(
'unsplashAccessKey'
);
if (!unsplashAccessKey) {
chrome.runtime.openOptionsPage();
return;
}
const metaResponse = await fetchPhotoMetadata(unsplashAccessKey);
const imageResponse = await fetch(metaResponse.urls.raw + '&q=85&w=2000');
if (!imageResponse.ok) {
throw new Error('Failed to fetch the image file.');
}
const cache = await caches.open(CACHE_NAME);
await cache.put(IMAGE_KEY, imageResponse);
await chrome.storage.local.set({ [METADATA_KEY]: metaResponse });
} catch (err) {
console.error('Error fetching and caching image:', err);
}
}
async function fetchPhotoMetadata(apiKey) {
const { collections } = await chrome.storage.local.get('collections');
let endpoint = 'https://api.unsplash.com/photos/random?orientation=landscape';
if (collections) {
endpoint += `&collections=${collections}`;
}
const headers = new Headers();
headers.append('Authorization', `Client-ID ${apiKey}`);
const response = await fetch(endpoint, { headers });
if (!response.ok) {
throw new Error(`Unsplash API error: ${response.statusText}`);
}
return response.json();
}
The script’s uses the chrome.runtime.onInstalled listener to detect when a
user first installs or updates the extension. On a fresh install it opens the
options page, and in all cases it calls fetchAndCacheImage() so there is at
least one image ready.
The fetchAndCacheImage() function checks chrome.storage.local for the user’s
saved unsplashAccessKey. If the key is missing, it simply re-opens the options
page and stops.
If the key is present, it calls the fetchPhotoMetadata() helper to get the
photo’s data (like its download URL and EXIF info). This helper builds the
correct Unsplash API URL, attaches the API key to the Authorization header,
and adds any custom collection IDs (if configured).
Once it has the metadata, fetchAndCacheImage() makes a second fetch call to
get the actual image file and requests a resized version for better performance.
It then opens the cache and uses cache.put to store the image data.
Finally, the metadata object into chrome.storage.local, where the extension
scripts can easily access it.
The constants referenced above are defined in js/constants.js:
// js/constants.js
export const CACHE_NAME = 'freshtab-image-cache-v1';
export const IMAGE_KEY = 'https://freshtab-app.local/next-image';
export const METADATA_KEY = 'next-image';
This extension needs to store two different kinds of data:
chrome.storage.local is designed for JSON serializable values like strings,
numbers, or objects. It cannot store large binary data like an image Response
or Blob.
That is why the Cache API is used for the image instead. However, note that the
IMAGE_KEY does not need to point to a real website. It only needs to be a
valid https:// URL so the cache accepts it (otherwise you’ll get an error).
If you want to store the image binary in chrome.storage.local instead, you
would need to convert it to a Base64 string and add the unlimitedStorage
permission, since high resolution images can easily exceed the default 10 MB
quota.
You can now reload the extension and confirm everything is working. Find the
freshtab entry in chrome://extensions and click the reload button. After a
few moments, open a new tab and launch the Chrome DevTools.
In the DevTools Application panel under Extension storage → Local, you
should see a next-image entry containing the metadata for the next tab’s
background image:
Under Cache storage, open freshtab-image-cache-v1 to see the saved image
response stored under the IMAGE_KEY URL:
With the image successfully cached, you are ready to display it on the new tab page.
Now that the Unsplash metadata and image are being saved to the extension’s local storage and cache, the new tab page can read them and display the photo.
The script responsible for this is js/index.js which runs every time a new tab
opens. Its job is to retrieve the cached image, show it as quickly as possible,
and notify the service worker to prepare the next image.
Open your js/index.js file and add the following code:
// js/index.js
import { CACHE_NAME, IMAGE_KEY } from './constants.js';
document.addEventListener('DOMContentLoaded', () => {
main();
});
async function main() {
await loadPhoto();
chrome.runtime.sendMessage({ command: 'next-image' });
}
async function loadPhoto() {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(IMAGE_KEY);
if (cachedResponse) {
const blob = await cachedResponse.blob();
document.body.style.backgroundImage = `url(${URL.createObjectURL(blob)})`;
} else {
document.body.style.backgroundColor = '#111';
}
}
When the page’s HTML has loaded, the main() function is triggered. This
function does two key things:
loadPhoto() to set the background image.next-image message to the service worker, telling it to
fetch a new photo in the background so it is ready for the next tab.The loadPhoto() function opens the browser cache and looks for an entry that
matches IMAGE_KEY. If it finds one, it converts the cached response into a
blob URL and applies it as the page background.
This way, a network request is avoided, making the image appear almost instantly. If nothing is cached yet (for example, on the first run), it falls back to the previous dark background color.
Before testing this, update your service worker so it listens for the
next-image command:
// js/background.js
// [...]
// Listen for the "next-image" command from the new tab page
chrome.runtime.onMessage.addListener((request) => {
if (request.command === 'next-image') {
fetchAndCacheImage();
}
});
Now reload the extension from the chrome://extensions page. Open a new tab and
you should be greeted with an image from Unsplash:
As you continue opening new tabs, you should see a different image each time,
thanks to the next-image message the page sends and the service worker
handles.
To make the extension’s settings easier to reach, let’s add a small popup that opens whenever the user clicks the extension’s toolbar icon.
The popup won’t do much, but it gives users an obvious way to access the options page without digging through menus.
Here is the popup.html file:
<!DOCTYPE html>
<html>
<head>
<title>Freshtab</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@1.0.0/css/bulma.min.css"
/>
<style>
body {
width: 200px;
padding: 1.25rem;
}
</style>
</head>
<body>
<h3 class="title is-5 has-text-centered">Freshtab</h3>
<button class="button is-primary is-fullwidth" id="options-btn">
Go to Settings
</button>
<script src="js/popup.js"></script>
</body>
</html>
The popup’s JavaScript is simple. It listens for a click on the button and opens the extension’s main options page:
// js/popup.js
document.addEventListener('DOMContentLoaded', () => {
const optionsButton = document.getElementById('options-btn');
if (optionsButton) {
optionsButton.addEventListener('click', () => {
chrome.runtime.openOptionsPage();
});
}
});
When you click the extension icon in Chrome’s toolbar, you’ll see a compact window with a single button:
While users can also right click the icon and choose Options, many people do not know that menu exists. A dedicated popup makes the settings far more discoverable and feels more polished.
If you like, you can take this further by adding a settings button directly on the new tab page as well.
By default, the extension selects a random landscape photo from the entire Unsplash library. To make the experience more personal, you can let users provide one or more Unsplash Collection IDs. When set, the extension will pull images only from those specific collections.
This feature is already built into both the options page and the
fetchPhotoMetadata() function in your background.js script, so no additional
code is required.
To try it out, visit the
Unsplash collections page and open any
collection. The collection ID appears in the page URL, as shown below:
Copy that ID, paste it into the Custom Collection IDs field on the options page, and click Save Settings:
From now on, whenever the extension fetches an image, it will restrict the request to the specified collection. The result is a curated, more consistent set of images:
One of the most exciting developments in 2025 is the integration of built-in, on-device language models directly into the browser. You can use this new LanguageModel API to generate motivational quotes for our new tab page.
But there’s a few caveats.
First, this is not a universal feature; it comes with strict hardware and software requirements. The AI model, Gemini Nano, requires a desktop machine with least 16 GB of RAM if running on the CPU, or a GPU with more than 4 GB of VRAM.
It’s also not available out of the box. The user must have an unmetered internet connection and at least 22 GB of free disk space for the initial model download.
And you can’t just start this download automatically; you first have to check the model’s availability and wait for a user activation (like a button click) before you can trigger the download by creating a session.
Because of all this, you can’t assume the feature will work for everyone, so it’s essential to build in a graceful fallback. In our case, this means checking if the model is available and simply not showing the quote if it isn’t.
Before you can generate anything, you should expose a setting that lets users turn this feature on or off.
In your options.html file, find and uncomment the following block:
<!-- options.html -->
<div class="field">
<label class="label">AI-powered motivational message</label>
<div class="control">
<label class="checkbox" id="quote-label">
<input type="checkbox" id="showQuote" />
Show a motivational quote on the new tab page from Chrome's built-in AI
model (Gemini Nano).
</label>
</div>
<p class="help" id="quote-help">
When enabled, a new quote will be fetched for each tab.
</p>
</div>
Since this feature relies on an API that might not be available on all devices, you must check for its availability first and update the UI accordingly.
Open js/options.js and replace its contents as follows:
// js/options.js
async function saveOptions() {
const key = document.getElementById('unsplashKey').value;
const collections = document.getElementById('collections').value;
const showQuote = document.getElementById('showQuote').checked;
await chrome.storage.local.set({
unsplashAccessKey: key,
collections,
showQuote,
});
const status = document.getElementById('status');
status.textContent = 'Options saved.';
setTimeout(() => {
status.textContent = '';
}, 1500);
}
async function restoreOptions() {
const result = await chrome.storage.local.get([
'unsplashAccessKey',
'collections',
'showQuote',
]);
document.getElementById('unsplashKey').value = result.unsplashAccessKey || '';
document.getElementById('collections').value = result.collections || '';
document.getElementById('showQuote').checked = result.showQuote ?? false;
}
async function checkAIAvailability() {
const quoteCheckbox = document.getElementById('showQuote');
const quoteLabel = document.getElementById('quote-label');
const quoteHelp = document.getElementById('quote-help');
const markUnavailable = () => {
quoteCheckbox.disabled = true;
quoteLabel.classList.add('is-disabled');
quoteHelp.textContent = 'This feature is not available in your browser';
quoteHelp.classList.add('is-danger');
};
if (!('LanguageModel' in self)) {
markUnavailable();
}
const availability = await LanguageModel.availability();
if (availability === 'available') return;
if (availability === 'unavailable') {
return markUnavailable();
}
}
document.addEventListener('DOMContentLoaded', async () => {
restoreOptions();
await checkAIAvailability();
});
document.getElementById('save').addEventListener('click', saveOptions);
Here is what this code does:
restoreOptions() loads any saved settings, including showQuote.saveOptions() persists the Unsplash key, collections, and the AI quote
toggle.checkAIAvailability() calls LanguageModel.availability() and, if the
feature is missing, disables the checkbox and shows a helpful message.If the API is available, the checkbox remains enabled and users can turn the feature on as normal.
You also need to handle the case where the model is downloadable but not yet installed. In that case Chrome will need to download it in the background and it is good practice to surface progress so users know what is happening.
To implement this, extend js/options.js with:
async function saveOptions() {
// [...]
if (showQuote) {
createModelSession();
}
}
async function createModelSession() {
const progress = document.getElementById('model-progress');
const quoteHelp = document.getElementById('quote-help');
try {
quoteHelp.textContent = 'Initializing download...';
progress.classList.remove('is-hidden');
const availability = await LanguageModel.availability();
if (availability === 'available') return;
const session = await LanguageModel.create({
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
quoteHelp.textContent = `Downloading AI model... (${Math.round(
e.loaded * 100
)}%)`;
progress.value = e.loaded;
if (e.loaded === 1) {
quoteHelp.textContent = 'Download complete, installing model...';
progress.removeAttribute('value');
}
});
},
});
session.destroy();
} catch (error) {
quoteHelp.textContent = error.message;
quoteHelp.classList.add('is-danger');
console.log(error);
} finally {
progress.classList.add('is-hidden');
progress.value = 0;
}
}
When the user enables the AI feature and clicks Save, createModelSession()
runs. It:
LanguageModel.create() with a monitor to track download
progress.Once the checkbox is ticked and the settings are saved, the
createModelSession() function is called to trigger the downloading of the
model. A progress bar is also displayed to give adequate feedback to the user.
Once the download is complete, your options page is ready and the model can be used on the new tab.
Next, wire the LanguageModel API into the new tab script itself. Open
js/index.js and update it as follows:
// js/index.js
import { CACHE_NAME, IMAGE_KEY } from './constants.js';
document.addEventListener('DOMContentLoaded', () => {
main();
displayQuote();
});
// [...]
async function runPrompt(prompt, params) {
let session;
try {
if (!session) {
session = await LanguageModel.create(params);
}
return session.prompt(prompt);
} catch (e) {
console.log('Prompt failed');
console.error(e);
console.log('Prompt:', prompt);
session.destroy();
}
}
async function displayQuote() {
const { showQuote } = await chrome.storage.local.get('showQuote');
if (!showQuote) return;
if (!('LanguageModel' in self)) return;
const prompt =
'Write a one-sentence motivational message about success, perseverance or discipline';
const params = {
expectedInputs: [{ type: 'text', languages: ['en'] }],
expectedOutputs: [{ type: 'text', languages: ['en'] }],
temperature: 2,
topK: 128,
};
const availability = await LanguageModel.availability(params);
if (availability !== 'available') {
return;
}
const quoteText = document.getElementById('quote-text');
const quoteAuthor = document.getElementById('quote-author');
try {
quoteText.textContent = 'Loading quote...';
const response = await runPrompt(prompt, params);
quoteText.textContent = response;
quoteAuthor.classList.remove('is-hidden');
} catch (e) {
console.log(e);
quoteText.textContent = '';
}
}
This script is what brings the new AI quote feature to life on your new tab page.
The displayQuote() function checks chrome.storage.local to see if the
feature is eanabled. If so, it the checks that the LanguageModel API exists
and that the model is available with the requested parameters. It then sets a
simple prompt asking for a short motivational sentence.
The expectedInputs and expectedOutputs fields specify that you want plain
text in English, while temperature and topK are set to high values to
encourage more varied output. For more ideas and tuning tips,
Prompt API documentation and
prompting strategies guide
While the prompt is running, the UI shows a Loading quote... message so the
user knows something is happening. When a response arrives, the quote text
replaces that message and the quote author element is revealed.
You can experiment with different prompts and parameters to change the tone, length, or style of the messages.
Extensions are made up of several moving parts: a service worker, popup, options page, and any overridden pages. When something breaks you need to know which piece to inspect.
Your main tool is the chrome://extensions page. Just make sure Developer
mode is toggled on in the top-right corner.
To debug your service worker (background.js), find your extension card on the
extensions page and click the service worker link:
Chrome opens a dedicated DevTools window where you can:
console.log output from the service worker.If your Chrome extension encounters a serious problem, you may also see an Errors button on the extension card:
Click it to open a panel that shows recent errors and stack traces:
After fixing the underlying issue, you can clear the errors and reload the extension.
To debug the popup, right click the extension icon in the toolbar and choose Inspect popup. This opens a DevTools window attached to the popup and keeps the popup from closing while you inspect it:
Finally, for new tab pages (or other browser overrides) you can debug them like
a regular web page by right-click anywhere on the page and selecting Inspect
or using the F12 shortcut key.
You’ve now built a full Chrome extension from scratch and gained a solid understanding of how extension parts fit together, how Manifest V3 works, and how to interact with the browser through its extension APIs.
You can see the full code in the final branch on GitHub
As you take on more advanced ideas, here are some helpful directions to explore:
For larger or more complext projects, writing everything by hand can get cumbersome. Frameworks like Wxt and Plasmo handle the boring stuff for you such as automatic reloading, streamlining builds, and making it easier to integrate modern JavaScript tooling.
The official Chrome Extensions Samples repository is also a great place to see real, working examples of every major API.
If you want to continue experimenting with the LanguageModel API, Chrome’s documentation offers practical examples and prompt patterns.
With these tools and references, you can expand this project into something more interesting or use it as a launching point for entirely new ideas.
Once you’re happy with your extension, you can publish it to the Chrome Web Store so others can install it. Google provides a step-by-step guide for the full submission process.
Happy building!
Comments
Ground rules
Please keep your comments relevant to the topic, and respectful. I reserve the right to delete any comments that violate this rule. Feel free to request clarification, ask questions or submit feedback.