Skip to article frontmatterSkip to article content

🧬 2 - Anatomy of an extension

🛠️ Setup

Dependency environment

We’ll use this environment for the rest of this workshop:

# Create an environment named "jupytercon2025"
micromamba create -n jupytercon2025

# Activate it
# IMPORTANT: Run this every time you open a new terminal!
micromamba activate jupytercon2025

# Install workshop dependencies
## python & pip: Python language and its official package installer
## nodejs: A JavaScript runtime
## gh: The GitHub CLI
## copier: A tool for quickstarting an extension from a template
## jinja2-time: A dependency of the official JupyterLab extension template
micromamba install python pip nodejs gh "copier~=9.2" jinja2-time

Important Git settings

  1. Git needs to know who you are. Configure identity information Git will use when we commit:

    git config --global user.email "your-email-here@example.com"
    git config --global user.name "Your Name Here"
  2. The modern conventional branch name is “main”, and this tutorial will assume that’s your default branch. Ensure your default branch is set to main:

    git config --global init.defaultBranch main

Create a GitHub repository and clone it locally

  1. Change to the parent directory where you want to work, e.g.

    cd ~/Projects
  2. If you don’t already have GitHub authentication set up on your local machine, authenticate with GitHub.

    gh auth login

    Select reasonable defaults: GitHub.com, HTTPS, Yes, and Login with a web browser, then follow the instructions carefully.

  3. Set up the Git CLI to authenticate with GitHub:

    gh auth setup-git
  4. Create a repository in GitHub and clone it:

    gh repo create jupytercon2025-extension-workshop --public --clone
  5. Change directory into your newly-cloned repository:

    cd jupytercon2025-extension-workshop
  6. Add some useful metadata to your repository:

    gh repo edit --add-topic "jupytercon2025" --add-topic "jupyterlab-extension"
  7. Get your cloned repository’s URL:

    gh repo view

    Copy the entire repository URL for the next step!

Extensions and plugins and widgets -- oh, my!

While they sound similar, extensions <extension> and plugins serve different purposes.

Plugins are JupyterLab’s fundamental building blocks which define functionality and business logic. Extensions are the delivery mechanism or “container” for plugins. Extensions are the thing that end-users pip install.

End-users care about extensions, and developers care about plugins.

A widget is a user interface component provided by a plugin, either for the end user to display (e.g. an interactive visualization of data) or for JupyterLab to display (e.g. a document viewer that opens when you double-click a particular file type).

What are we building together?

First, we’ll examine and demonstrate the extension we’re going to build today.

Our extension will:

🚀 Let’s build it together from scratch.

🏋️ Exercise A (15 minutes): Extension creation and development loop

Create a new extension from the official template

  1. Instantiate the template to get started on our new extension!

    copier copy --trust https://github.com/jupyterlab/extension-template .

    Please be sure to correctly input:

    • Your name and e-mail

    • Kind: frontend-and-server

    • Javascript package name: jupytercon2025-extension-workshop

    • Repository URL: as printed by the gh repo view command in the previous step

    The remaining values can be left as default.

    A demo of instantiating an extension project from the official template
  2. List the files that were created (ls -la or tree -a are good options).

  3. Install the extension in development mode

    # Install package in development mode
    pip install --editable ".[dev,test]"
  4. Connect the extension, frontend and server, to JupyterLab

    jupyter labextension develop . --overwrite
    jupyter server extension enable jupytercon2025_extension_workshop
  5. Build the extension

    # Rebuild extension Typescript source after making changes
    # IMPORTANT: We must do this every time we make a change!
    jlpm build

🧪 Test

  1. Start JupyterLab in a separate terminal.

    After running this command, we can keep this terminal open and running JupyterLab in the background!

    jupyter lab
  2. Confirm the extension was loaded. Open your browser’s dev console (F12 or CTRL+SHIFT+I) and...

  3. Test the server endpoint by visiting it in your browser (http://localhost:8888/jupytercon2025-extension-workshop/hello).

Do a complete development loop

  1. Close the JupyterLab server with CTRL+C.

  2. Make any change to the codebase. For example, alter the text in a console.log() message. We suggest changing Hello, world! in the server’s message (in jupytercon2025_extension_workshop/routes.py) to Hello, <your-name-here>!.

  3. Rebuild the extension with jlpm build.

🧪 Test

Follow the same testing steps as last time.

Please repeat this development loop as many times as you can to get more comfortable with it.

What just happened?

We know how to get started: we learned how to instantiate a new extension from the official template and set it up for development.

We know how to iterate: we learned that the JupyterLab extension development loop is...

Now we have all the knowledge we need to keep iterating on our extension! 🎓 Well done!

Creating a widget

Our working extension is a basic “hello, world” application. All it does is log a string to the console, then make a request to the back-end for another string, which is also logged to the console. This all happens once, when the extension is activated when the user opens JupyterLab.

Our goal is to display a viewer for a random photo and caption, with a refresh button to instantly display a new image. That viewer will be a Widget, so let’s start by creating a widget that will eventually house that content.

🏋️ Exercise B (20 minutes): Launching a “hello, world” widget

Create a “hello, world” widget

To display this widget in the main area, we need to implement a widget which displays our content (for now, just “Hello, world!”), and then include that content in a main area widget.

Create a new file src/widget.ts and add the widget code:

src/widget.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Widget } from '@lumino/widgets';
import { MainAreaWidget } from '@jupyterlab/apputils';
import {
  imageIcon,
} from '@jupyterlab/ui-components';

class ImageCaptionWidget extends Widget {
  // Initialization
  constructor() {
    super();

    // Create and append an HTML <p> (paragraph) tag to our widget's node in
    // the HTML document
    const hello = document.createElement('p');
    hello.innerHTML = "Hello, world!";
    this.node.appendChild(hello);
  }
}

export class ImageCaptionMainAreaWidget extends MainAreaWidget<ImageCaptionWidget> {
  constructor() {
    const widget = new ImageCaptionWidget();
    super({ content: widget });

    this.title.label = 'Random image with caption';
    this.title.caption = this.title.label;
    this.title.icon = imageIcon;
  }
}

Our widget is using JavaScript to define HTML elements that will appear in the widget. That looks like this:

And the HTML looks roughly like this:

<div id="our-widget">
  <p>Hello, world!</p>
</div>

We can’t test this because we don’t have a convenient way to display the widget in JupyterLab yet. Let’s fix that now.

Create a command to display the widget in the main area

In src/index.ts, we need to update our plugin to define a command in our plugin's activate method:

src/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { requestAPI } from './request';
import { ImageCaptionMainAreaWidget } from './widget';

/**
 * Initialization data for the jupytercon2025-extension-workshop extension.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  id: 'jupytercon2025-extension-workshop:plugin',
  description: 'A JupyterLab extension that displays a random image and caption.',
  autoStart: true,
  activate: (
    app: JupyterFrontEnd,
  ) => {
    console.log('JupyterLab extension jupytercon2025-extension-workshop is activated!');

    requestAPI<any>('hello')
      .then(data => {
        console.log(data);
      })
      .catch(reason => {
        console.error(
          `The jupytercon2025_extension_workshop server extension appears to be missing.\n${reason}`
        );
      });

    //Register a new command:
    const command_id = 'image-caption:open';
    app.commands.addCommand(command_id, {
      execute: () => {
        // When the command is executed, create a new instance of our widget
        const widget = new ImageCaptionMainAreaWidget();

        // Then add it to the main area:
        app.shell.add(widget, 'main');
      },
      label: 'View a random image & caption'
    });
  }
};

But right now, this command is not being used by anything! Next, we’ll add it to the command palette.

Register our command with the command palette

First, import the command palette interface at the top of src/index.ts:

src/index.ts
1
2
3
4
5
import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { ICommandPalette } from '@jupyterlab/apputils';

Then, add the command palette as a dependency of our plugin:

src/index.ts
1
2
3
4
5
6
7
8
9
10
11
const plugin: JupyterFrontEndPlugin<void> = {
  id: 'myextension:plugin',
  description: 'A JupyterLab extension.',
  autoStart: true,
  requires: [ICommandPalette],  // dependencies of our extension
  activate: (
      app: JupyterFrontEnd,
      // The activation method receives dependencies in the order they are specified in
      // the "requires" parameter above:
      palette: ICommandPalette
  ) => {

Finally, we can use our palette object to register our command with the command palette.

src/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    //Register a new command:
    const command_id = 'image-caption:open';
    app.commands.addCommand(command_id, {
      execute: () => {
        // When the command is executed, create a new instance of our widget
        const widget = new ImageCaptionMainAreaWidget();

        // Then add it to the main area:
        app.shell.add(widget, 'main');
      },
      icon: imageIcon,
      label: 'View a random image & caption'
    });

    palette.addItem({ command: command_id, category: 'Tutorial' });

🧪 Test

Stop your JupyterLab server (CTRL+C), then rebuild your extension (jlpm build), then restart JupyterLab (jupyter lab).

If everything went well, now you can test the extension in your browser.

To test from the command palette, click “View”>“Commands” from the menu bar, or use the shortcut CTRL+SHIFT+C. Begin typing “Random image” and the command palette interface should autocomplete. Select “Random image with caption” and press ENTER.

Optional: Register with the launcher

Unlike the command palette, this functionality needs to be installed as a dependency. First, install @jupyterlab/launcher with jlpm add @jupyterlab/launcher to make this dependency available for import.

We can import ILauncher with:

src/index.ts
import { ILauncher } from '@jupyterlab/launcher'

Don’t forget to add the launcher as a dependency (requires) of our plugin, and to pass the dependency in to the activate function.

...and register our Command with the Launcher:

src/index.ts
launcher.add({ command: command_id });

The rest of the implementation up to you!

🧪 Test

Repeat the build and test procedure from the previous step.

Open a new tab with the + button at the top of the main area and click the new button in the launcher.

My launcher button works, but it has no icon!

Adding an icon is one extra step. We can import the icon in src/index.ts like so:

src/index.ts
import { imageIcon } from '@jupyterlab/ui-components';

and add the icon to the command’s metadata:

src/index.ts
1
2
3
4
5
6
7
8
9
    app.commands.addCommand(command, {
      execute: () => {
        const widget = new ImageCaptionMainAreaWidget();

        app.shell.add(widget, 'main');
      },
      icon: imageIcon,
      label: 'View a random image & caption'
    });

Give it another test, and you should see an icon.

What’s next?

We’ve graduated from “Hello, world” in the console to “Hello, world” in a main area widget. That’s a big step, but remember our end goal: A viewer for random images and captions.

We have all the building blocks now: a server to serve the image data from disk with a caption, and a widget to display them. Now we need to implement the logic and glue the pieces together.

🏋️ Exercise C (20 minutes): Serve images and captions from the server extension

Set up images and captions

Create a new directory at jupytercon2025_extension_workshop/images:

mkdir jupytercon2025_extension_workshop/images

Then, place images in this directory. You can choose your own images (favorite cat pictures?) or download images from our demo repository.

Now we need a way to associate captions with each image. We’ll use a list of Python dictionaries (mappings) to do so. Create a new file jupytercon2025_extension_workshop/images_and_captions.py and populate it with:

jupytercon2025_extension_workshop/images_and_captions.py
# Public domain images from https://www.loc.gov/free-to-use/cats/
IMAGES_AND_CAPTIONS = [
    { "filename": "brunnhilde.jpg", "caption": "Brünnhilde" },
    { "filename": "cats.jpg", "caption": "Cats" },
    { "filename": "cat-cher-evolution.jpg", "caption": "Evolution of a cat-cher" },
    { "filename": "the-entanglement.jpg", "caption": "The entanglement" },
]

Update the server to serve images and captions

Our server behaviors are defined in jupytercon2025_extension_workshop/routes.py, so that module will need to know about how to access our image data and the associated captions. We’ll import the data structure we just defined and define a new Path object that references the images directory we just created. We’ll need the random standard library module in the next step, so we’ll import that too while we’re here:

jupytercon2025_extension_workshop/routes.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import base64
import json
import random
from pathlib import Path

from jupyter_server.base.handlers import APIHandler
from jupyter_server.utils import url_path_join
import tornado

from .images_and_captions import IMAGES_AND_CAPTIONS

IMAGES_DIR = Path(__file__).parent.absolute() / "images"


class HelloRouteHandler(APIHandler):
    ...

Next, we’ll set up a new route handler in our server extension. This route handler will select a random entry from the IMAGES_AND_CAPTIONS constant we imported, open that image, encode its data as a string, and then return the string-encoded image data alongside the caption.

jupytercon2025_extension_workshop/routes.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class HelloRouteHandler(APIHandler):
    ...


class ImageAndCaptionRouteHandler(APIHandler):
    @tornado.web.authenticated
    def get(self):
        random_selection = random.choice(IMAGES_AND_CAPTIONS)

        # Read the data and encode the bytes in base64
        with open(IMAGES_DIR / random_selection["filename"], "rb") as f:
            b64_bytes = base64.b64encode(f.read()).decode("utf-8")

        self.finish(json.dumps({
            "b64_bytes": b64_bytes,
            "caption": random_selection["caption"],
        }))

Finally, we need to connect our new handler to the appropriate route:

jupytercon2025_extension_workshop/routes.py
1
2
3
4
5
6
7
8
9
10
11
12
def setup_route_handlers(web_app):
    host_pattern = ".*$"
    base_url = web_app.settings["base_url"]

    hello_route_pattern = url_path_join(base_url, "jupytercon2025-extension-workshop", "hello")
    image_route_pattern = url_path_join(base_url, "jupytercon2025-extension-workshop", "random-image-caption")
    handlers = [
        (hello_route_pattern, HelloRouteHandler),
        (image_route_pattern, ImageAndCaptionRouteHandler),
    ]

    web_app.add_handlers(host_pattern, handlers)

🧪 Test

Now’s the best time for us to stop and test before moving on to consuming this data with our widget. Does our server endpoint return the data we expect it to? If there’s something wrong and we jump straight to working on the UI, we could have a bad time.

Since we only altered Python code, we don’t need to run jlpm build. We can see our changes by restarting JupyterLab and visiting http://localhost:8888/jupytercon2025-extension-workshop/random-image-caption.

Refresh the page several times, and you should see the data change with each refresh (except when the same image is randomly selected multiple times in a row!).

Connect the Widget to the Server extension

Now that our backend is working, we need to glue our widget to it.

First, let’s import a utility function to src/widget.ts that will handle requesting an image and caption from the server:

src/widget.ts
1
2
3
4
5
6
7
import { Widget } from '@lumino/widgets';
import { MainAreaWidget } from '@jupyterlab/apputils';
import {
  imageIcon,
} from '@jupyterlab/ui-components';

import { requestAPI } from './request';

Now, let’s add the behavior to our widget which uses requestAPI to communicate with the server. This change adds a new method load_image() to our widget class, but notice that nothing is calling that method yet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ImageCaptionWidget extends Widget {
  // Initialization
  constructor() {
    // ...
  }

  // Fetch data from the server extension and save the results to img and
  // caption class attributes
  load_image(): void {
    requestAPI<any>('random-image-caption')
      .then(data => {
        console.log(data);
        this.img.src = `data:image/jpeg;base64, ${data.b64_bytes}`;
        this.caption.innerHTML = data.caption;
      })
      .catch(reason => {
        console.error(`Error fetching image data.\n${reason}`);
      });
  }

  // Information about class attributes for the type checker
  img: HTMLImageElement;
  caption: HTMLParagraphElement;
}

Finally, let’s hook this behavior up to display it visually. Now, we’re calling load_image() when we initialize the widget:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class ImageCaptionWidget extends Widget {
  // Initialization
  constructor() {
    super();

    // Create and append an HTML <p> (paragraph) tag to our widget's node in
    // the HTML document
    const hello = document.createElement('p');
    hello.innerHTML = "Hello, world!";
    this.node.appendChild(hello);

    const center = document.createElement('center');
    this.node.appendChild(center);

    // Put an <img> tag into the <center> tag, and also save it as a class
    // attribute so we can update it later.
    this.img = document.createElement('img');
    center.appendChild(this.img);

    // Do the same for a caption!
    this.caption = document.createElement('p');
    center.appendChild(this.caption);

    // Initialize the image from the server extension
    this.load_image();
  }

  // Fetch data from the server extension and save the results to img and
  // caption class attributes
  load_image(): void {
    // ...
  }

  // Information for the type checker
  img: HTMLImageElement;
  caption: HTMLParagraphElement;
}

🧪 Test

Now that we have our widget user interface hooked up to the data coming from the server, let’s test again. Because we changed the JavaScript, we need to use jlpm run build, but we don’t need to restart the JupyterLab server. We just need to refresh the page!

🏋️ Exercise D (15 minutes): Add user interactivity to the widget

Right now, you only get a random image when you first open the widget. It’s much more interesting if the widget can respond to user actions! Let’s add a toolbar and refresh button which triggers the image to change immediately.

Import a toolbar UI component and icon

For all of this to work, we need the ToolbarButton to use in our widget. For our toolbar button to be usable, it also needs an icon. We can import refreshIcon from the same place we got imageIcon:

src/widget.ts
1
2
3
4
5
6
7
8
9
import { Widget } from '@lumino/widgets';
import {
  MainAreaWidget,
  ToolbarButton,
} from '@jupyterlab/apputils';
import {
  imageIcon,
  refreshIcon,
} from '@jupyterlab/ui-components';

Add the button to the widget and connect the logic

Now we can use the ToolbarButton class to instantiate a new button with an icon, tooltip, and behavior (onClick).

For the button’s behavior, we’ll reuse our widget’s load_image() method that we call when we initialize the widget.

src/widget.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export class ImageCaptionMainAreaWidget extends MainAreaWidget<ImageCaptionWidget> {
  constructor() {
    const widget = new ImageCaptionWidget();
    super({ content: widget });

    this.title.label = 'Random image with caption';
    this.title.caption = this.title.label;
    this.title.icon = imageIcon;

    // Add a refresh button to the toolbar
    const refreshButton = new ToolbarButton({
      icon: refreshIcon,
      tooltip: 'Refresh image',
      onClick: () => {
        widget.load_image();
      }
    });
    this.toolbar.addItem('refresh', refreshButton);
  }
}

🧪 Test

Build with jlpm build and then refresh your browser.

Problem: The widget disappears when we refresh the page

🏋️ Exercise E (15 minutes): Preserve layout

JupyterLab can save and restore layouts, but we need to define how our widget restores its state.

First, let’s import the layout restorer:

src/index.ts
1
2
3
4
5
6
7
8
9
import {
  ILayoutRestorer,
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';
import {
  ICommandPalette,
  WidgetTracker
} from '@jupyterlab/apputils';

Now, we’ll define the layout restorer token as an optional dependency, as it may not be available in all JupyterLab deployments. When we pass an optional dependency to the activate function, we follow two key rules:

  1. Optional dependencies are passed after required dependencies

  2. Optional dependencies always have the possibility of being null

src/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const plugin: JupyterFrontEndPlugin<void> = {
  id: 'jupytercon2025-extension-workshop:plugin',
  description: 'A JupyterLab extension that displays a random image and caption.',
  autoStart: true,
  requires: [ICommandPalette, ILauncher],  // dependencies of our extension
  optional: [ILayoutRestorer],
  activate: (
    app: JupyterFrontEnd,
    // The activation method receives dependencies in the order they are specified in
    // the "requires" parameter above:
    palette: ICommandPalette,
    launcher: ILauncher,
    restorer: ILayoutRestorer | null
  ) => {

Now that we have the dependency, we need to define how the widget’s layout will be saved and restored. First, we need to define a tracker object:

src/index.ts
1
2
3
4
5
6
7
8
9
10
    // Track widget state
    const tracker_namespace = 'jupytercon2025-extension-workshop';
    const tracker = new WidgetTracker<ImageCaptionMainAreaWidget>({
      namespace: tracker_namespace
    });

    //Register a new command:
    const command_id = 'image-caption:open';
    app.commands.addCommand(command_id, {
      execute: () => {

Then, add our widget to the tracker:

src/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    app.commands.addCommand(command_id, {
      execute: () => {
        // When the command is executed, create a new instance of our widget
        const widget = new ImageCaptionMainAreaWidget();

        if (!tracker.has(widget)) {
          tracker.add(widget);
        }

        // Then add it to the main area:
        app.shell.add(widget, 'main');
      },
      icon: imageIcon,
      label: 'View a random image & caption'
    });

And finally, restore any previous state when our plugin is activated:

src/index.ts
1
2
3
4
5
6
7
8
9
10
    palette.addItem({ command: command_id, category: 'Tutorial' });
    launcher.add({ command: command_id });

    // Restore widget state
    if (restorer) {
      restorer.restore(tracker, {
        command: command_id,
        name: () => tracker_namespace
      });
    }

🧪 Test

To test this change, load our widget in JupyterLab, then refresh the page.

Footnotes
  1. We don’t actually always need to rebuild -- only when we change the JavaScript. If we only changed Python, we only need to restart JupyterLab.