Inspired by...
This tutorial is inspired by many prior works.
JupyterCon 2018 tutorial: repo
JupyterCon 2020 tutorial: repo
The official Astronomy picture of the day tutorial with a few changes (some of which we plan to merge back):
Include a server extension
Add an interactive element (toolbar button) to refresh the image
Use a set of public domain images from the Library of Congress (the NASA astronomy photo API is not reliable during the current government shutdown 😭)
Remove unnecessary use of
condain favor of more standard toolingStructure the activities around in person teaching and exercises
Extensions and plugins and widgets -- oh, my!¶
An extension and a plugin sound like the same thing at first. The important thing to understand is that plugins are functionality, and extensions are the package around that functionality.
Plugins are the fundamental building block of the JupyterLab architecture, and
extensions are the delivery mechanism or “container” for those building blocks.
Extensions are the thing you pip install, and they often contain more than one plugin.
A widget is a user interface component provided by a plugin, either for use by 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).
Now we’re ready to build an extension!¶
First, let’s look at the extension we’re going to build today. The README in this repository describes the functionality we will build out.
First we’ll look at the final extension together. It:
Adds a new button to the launcher
Adds a new command to the command palette
When either of those is triggered, it opens a new tab/window with a viewer showing a random image and caption from a small curated collection of public domain images.
The viewer also has a “refresh” button to trigger fetching a new image and caption.
🚀 Now, let’s build it together from scratch.
🏋️ Exercise A (15 minutes): Extension creation and development loop¶
Create a repository in GitHub and clone it¶
Change to the parent directory where you want to work, e.g.
cd ~/ProjectsCreate a repository in GitHub and clone it
# TODO: Test gh repo create jupytercon2025-extension-workshop --public --cloneChange directory into your new repository
cd jupytercon2025-extension-workshop
First, create a new extension from the official template¶
Instantiate the template to get started on your new extension!
copier copy --trust https://github.com/jupyterlab/extension-template .Please input:
Kind:
frontend-and-serverJavascript package name:
jupytercon2025-extension-workshop
Everything else can be left as default if you prefer.

List out the files that were created (
ls -laortree -aare good options)Install the extension in development mode
# Create and activate a virtualenv uv venv .venv source .venv/bin/activate # Install package in development mode uv pip install --editable ".[dev,test]" # Install the frontend and backend components of the extension in development mode: jupyter labextension develop . --overwrite jupyter server extension enable jupytercon2025_extension_workshop # Rebuild extension Typescript source after making changes # IMPORTANT: You must do this every time you make a change! jlpm build🧪 Test it out! Run this command in a separate terminal. It will open JupyterLab in your browser automatically. Remember to activate the virtual environment again with
source .venv/bin/activateany time you create a new terminal. You can keep this terminal open and running JupyterLab in the background!jupyter labConfirm the extension was loaded. Open your browser’s dev console (F12 or
CTRL+SHIFT+I) and look for log messages reading:JupyterLab extension jupytercon2025-extension-workshop is activated!Hello, world! This is the '/jupytercon2025-extension-workshop/hello' endpoint. Try visiting me in your browser!
If you do not see these messages, let us know you need help!
Directly test the server portion of the extension by visiting the endpoint in your browser (
http://localhost:8888/jupytercon2025-extension-workshop/hello). You should see the same message as the last step:Hello, world! This is the '/jupytercon2025-extension-workshop/hello' endpoint. Try visiting me in your browser!
Now let’s do one complete development loop!¶
Close the JupyterLab server with
CTRL+C.Make any change to the codebase. For example, alter the text in a
console.log()message. We suggest changingHello, world!in the server’s message (injupytercon2025_extension_workshop/routes.py) toHello, <your-name-here>!.Rebuild the extension with
jlpm build.Start JupyterLab again with
jupyter lab.Test again following steps 5 & 6 above. Do you see the change in the console messages? Do you see the change when directly accessing the server with the browser?
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...
Make a change to the code.
Shut down JupyterLab (
CTRL+C).Rebuild the extension with
jlpm build[1].Start JupyterLab with
jupyter lab.
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:
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 29import { 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:
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 39import { 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:
1 2 3 4 5import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; import { ICommandPalette } from '@jupyterlab/apputils';
Then, add the command palette as a dependency of our plugin:
1 2 3 4 5 6 7 8 9 10 11const 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.
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' });
Finally, we can 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.
You should see a new tab open containing the text “Hello, world”!
Optional: Register with the launcher¶
Unlike the command palette, this functionality needs to be installed.
First, install @jupyterlab/launcher with jlpm add @jupyterlab/launcher to make
this dependency available for import.
You can import ILauncher with:
import { ILauncher } from '@jupyterlab/launcher'...and register your Command with the Launcher:
launcher.add({ command: command_id });We will leave the rest of the implementation up to you!
Test it!¶
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:
import { imageIcon } from '@jupyterlab/ui-components';and add the icon to the command’s metadata:
1 2 3 4 5 6 7 8 9app.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: Serve images and captions from the server extension¶
🏋️ Exercise D: Add user interactivity to the widget¶
You don’t actually always need to rebuild -- only when you change the JavaScript. If you only changed Python, you only need to restart JupyterLab.