backJanuary 20, 2026

Writing a GNOME Shell Extension from Scratch

If you use GNOME, you've probably installed a Shell extension at some point. Maybe to tweak the top panel or add a clipboard manager. But have you ever thought about writing one yourself?

I recently built one myself: a search provider that fetches random excuses from the No-as-a-Service API. Type "no" in the GNOME overview, get a creative excuse. Click it, and it copies to your clipboard. Useless? Maybe. Fun to build? Yep!

In this post I'll walk you through writing this extension from scratch. By the end you'll have a working search provider, and a solid understanding of how GNOME Shell extensions work.

What is a GNOME Shell extension?

A GNOME Shell extension is a small JavaScript module that hooks into the running Shell process. It can modify behavior, add UI elements to the panel, or change how windows and workspaces behave. Extensions are written in GJS, which is JavaScript with bindings to the GNOME platform APIs (GTK, GLib, Clutter, Mutter, etc.).

Since GNOME 45, extensions use standard ECMAScript modules (ESModules). If you've worked with import/export in modern JavaScript, this will look familiar.

Scaffolding the extension

GNOME ships with a tool to create the skeleton for a new extension:

gnome-extensions create --interactive

It will ask you a few things:

Name: NaaS GNOME Search
Description: Search provider that fetches excuses from the No-as-a-Service API
UUID: [email protected]
Template: 1 (Plain)

The UUID is a globally unique identifier in email format. It also determines the directory name where your extension lives:

~/.local/share/gnome-shell/extensions/[email protected]/

You can also create the directory structure manually. An extension needs at minimum two files: metadata.json and extension.js.

The file structure

For our search provider, the structure looks like this:

[email protected]/
├── extension.js
├── searchProvider.js
└── metadata.json

Two required files, plus searchProvider.js where we'll put the actual search logic. Keeping the search provider in a separate file keeps things clean.

metadata.json

This file tells GNOME Shell about your extension:

{
    "uuid": "[email protected]",
    "name": "NaaS GNOME Search",
    "description": "Search provider that fetches excuses from the No-as-a-Service API",
    "shell-version": ["47", "48", "49"],
    "url": "https://github.com/joostvanmeeuwen/gnome-search-no-as-a-service"
}

The uuid must match the directory name. The shell-version array defines which GNOME Shell versions your extension supports.

extension.js

This is the entry point. Your extension must export a default class with two methods: enable() and disable().

import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';

import {NaasSearchProvider} from './searchProvider.js';

export default class NaasSearchExtension extends Extension {
    enable() {
        this._searchProvider = new NaasSearchProvider(this);
        Main.overview.searchController.addProvider(this._searchProvider);
    }

    disable() {
        if (this._searchProvider) {
            Main.overview.searchController.removeProvider(this._searchProvider);
            this._searchProvider = null;
        }
    }
}

enable() is called when the extension is turned on. disable() is called when it's turned off, when the screen locks, or when the extension is uninstalled.

Everything you do in enable() must be undone in disable(). This is the number one reason extensions get rejected during review on extensions.gnome.org. We add the search provider in enable(), so we remove it in disable() and null the reference.

In this case we're registering a custom search provider with Main.overview.searchController.addProvider(). This is how you hook into GNOME's overview search, the one that pops up when you press the Super key and start typing.

Building the search provider

A search provider needs to implement a specific interface that GNOME Shell expects. Let's build searchProvider.js step by step.

The skeleton

import St from 'gi://St';
import Soup from 'gi://Soup';
import GLib from 'gi://GLib';

export class NaasSearchProvider {
    constructor(extension) {
        this._extension = extension;
        this._cache = [];
        this._maxCacheSize = 20;
    }

    get appInfo() {
        return null;
    }

    get canLaunchSearch() {
        return false;
    }

    get id() {
        return this._extension.uuid;
    }
}

The constructor takes the extension object so we can access the UUID. We also set up a cache (more on that later).

Three getters are required. appInfo returns null for extensions since we don't have a separate desktop app. canLaunchSearch is false because there's no external app to open. id returns the extension UUID.

Deciding when to show results

GNOME calls getInitialResultSet() every time the user types something in the overview. This is where you decide if your extension should show results for the current search query:

async getInitialResultSet(terms, cancellable) {
    return new Promise((resolve, reject) => {
        const cancelledId = cancellable.connect(
            () => reject(Error('Search Cancelled')));

        const query = terms.join(' ').toLowerCase();
        const keywords = ['nee', 'no'];
        const hasMatch = keywords.some(keyword => query.includes(keyword));

        const identifiers = hasMatch ? ['excuse-1'] : [];

        cancellable.disconnect(cancelledId);
        if (!cancellable.is_cancelled()) {
            resolve(identifiers);
        }
    });
}

terms is an array of words the user has typed. We join them and check if they contain "nee" or "no". If they do, we return an array with an identifier. If not, we return an empty array and GNOME won't show our extension in the results.

The cancellable parameter is important. GNOME passes this so it can cancel your search if the user types something new before you've returned results. Always handle it.

There are two more methods that GNOME Shell calls during the search lifecycle:

async getSubsearchResultSet(results, terms, cancellable) {
    if (cancellable.is_cancelled()) {
        throw Error('Search Cancelled');
    }

    return this.getInitialResultSet(terms, cancellable);
}

filterResults(results, maxResults) {
    if (results.length <= maxResults) {
        return results;
    }

    return results.slice(0, maxResults);
}

getSubsearchResultSet() is called when the user refines their search (types more characters). We just delegate to getInitialResultSet(). filterResults() truncates the results if GNOME requests a maximum.

Fetching excuses from the API

Once GNOME knows we have results, it calls getResultMetas() to get the actual content to display. This is where we hit the No-as-a-Service API:

async getResultMetas(results, cancellable) {
    const {scaleFactor} = St.ThemeContext.get_for_stage(global.stage);

    return new Promise((resolve, reject) => {
        const cancelledId = cancellable.connect(
            () => reject(Error('Operation Cancelled')));

        const session = new Soup.Session();
        const message = Soup.Message.new('GET', 'https://naas.isalman.dev/no');

        session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, cancellable, (_session, result) => {
            try {
                const bytes = session.send_and_read_finish(result);
                const decoder = new TextDecoder('utf-8');
                const text = decoder.decode(bytes.get_data());
                const data = JSON.parse(text);

                const excuse = data.reason;
                this._addToCache(excuse);

                const resultMetas = this._createResultMeta(results, excuse, scaleFactor);

                cancellable.disconnect(cancelledId);
                if (!cancellable.is_cancelled()) {
                    resolve(resultMetas);
                }
            } catch (error) {
                console.error('NaaS API error:', error);
                cancellable.disconnect(cancelledId);

                if (this._cache.length > 0) {
                    const randomExcuse = this._cache[Math.floor(Math.random() * this._cache.length)];
                    const resultMetas = this._createResultMeta(results, randomExcuse, scaleFactor);
                    resolve(resultMetas);
                } else {
                    reject(error);
                }
            }
        });
    });
}

A few things to note here.

Soup is GNOME's HTTP library. Soup.Session creates an HTTP client, and send_and_read_async makes a non-blocking request. This is important: you never want to block the Shell's main loop with a synchronous HTTP call.

The scaleFactor from St.ThemeContext is used for HiDPI displays. We pass it to our icon creation so it renders at the right size.

If the API call fails (network down, timeout, whatever), we fall back to a cached excuse. If the cache is empty too, we reject the promise and GNOME simply won't show a result.

Creating the result metadata

Each result needs a metadata object that GNOME Shell knows how to render:

_createResultMeta(results, excuse, scaleFactor) {
    const resultMetas = [];

    for (const identifier of results) {
        const meta = {
            id: identifier,
            name: excuse,
            description: 'Click or press Enter to copy full excuse',
            clipboardText: excuse,
            createIcon: size => {
                return new St.Icon({
                    icon_name: 'action-unavailable-symbolic',
                    width: size * scaleFactor,
                    height: size * scaleFactor,
                });
            },
        };

        resultMetas.push(meta);
    }

    return resultMetas;
}

name is the main text shown in the result. description appears below it. clipboardText is a built-in feature: when the user activates the result, GNOME automatically copies this text to the clipboard. createIcon returns an St.Icon widget. We use action-unavailable-symbolic because, well, the answer is no.

Caching

Since we're calling an external API, it makes sense to cache results. The caching logic is simple:

_addToCache(excuse) {
    if (!this._cache.includes(excuse)) {
        this._cache.push(excuse);

        if (this._cache.length > this._maxCacheSize) {
            this._cache.shift();
        }
    }
}

We store up to 20 unique excuses. When the cache is full, the oldest one gets removed. If the API goes down, we pick a random excuse from the cache as a fallback.

Handling activation

Two more methods to complete the interface. activateResult() is called when the user clicks or presses Enter on a result. Since we're using clipboardText in the result meta, GNOME handles the clipboard copy for us:

activateResult(_result, _terms) {
    // Result activated, excuse copied to clipboard
}

launchSearch(_terms) {
    // Not implemented - no separate app to launch
}

launchSearch() would open an external application, but we don't have one.

Testing the extension

Copy the files to your extension directory:

mkdir -p ~/.local/share/gnome-shell/extensions/[email protected]
cp extension.js searchProvider.js metadata.json ~/.local/share/gnome-shell/extensions/[email protected]/

Enable the extension:

gnome-extensions enable [email protected]

Now press the Super key, type "no", and you should see an excuse pop up in the search results. Click it to copy to your clipboard.

Debugging

Watch the logs in real time:

journalctl -f -o cat /usr/bin/gnome-shell

Filter for your extension specifically:

journalctl -f /usr/bin/gnome-shell | grep -i "naas\|error"

GNOME Shell also has a built-in debugger called Looking Glass. Press Alt+F2, type lg, and hit Enter. It gives you a JavaScript console where you can inspect objects and test code snippets.

Publishing

When your extension is ready, package it as a zip:

gnome-extensions pack [email protected]

You can submit it to extensions.gnome.org where it goes through a review process. The main thing reviewers check: does disable() properly undo everything enable() did?

The full source code for this extension is on GitHub, and you can install it directly from extensions.gnome.org.

Now go build something!

We built a search provider, but there's much more you can do with extensions. Panel indicators with dropdown menus using PanelMenu.Button. Custom keybindings with Main.wm.addKeybinding(). Window management through Mutter's Meta.Window API. Quick settings toggles since GNOME 44.

The best resource is the GJS Guide, which covers everything from basic concepts to advanced patterns. The GNOME Shell source code itself is also worth reading. Since you're patching a live JavaScript application, reading the source is often the fastest way to figure out what's possible.