Bridget

HTMX + FastHTML - Server for Jupyter Notebooks

Why Bridget?

Jupyter notebooks serve two primary functions: 1. Exploratory/Educational: Interactive computing and learning 2. Development: Literate programming and package creation (via nbdev)

While code creation is well-served by various notebook environments (Jupyter, VSCode, Marimo, etc.), the display and interaction capabilities are often limited by IPython’s well-thought but basic and aging display system. Modern development requires:

  • Rich data visualization
  • Interactive system integration
  • Better control over output rendering

The Front-end Challenge

Notebooks already run in browsers (or browser-like environments), giving us access to powerful HTML/JavaScript capabilities. The front-end and kernel are inherently connected, suggesting we shouldn’t need additional communication layers.

However, the notebook ecosystem is fragmented: - Jupyter (nbclassic, Notebook, Lab): ipywidgets - VSCode/Cursor: Extensions - Marimo: Custom solutions - Google Colab: Proprietary package

This fragmentation forces users needing better display solutions to deal with complex, environment-specific tooling.

Enter FastHTML and HTMX

FastHTML provides an innovative approach to web apps, emphasizing HTML-first development through HTMX. While Jupyter support isn’t its core focus, FastHTML itself is developed using nbdev in notebooks.

Current FastHTML notebook integration: - Launches a separate Uvicorn server - Connects HTMX via standard HTTP/AJAX - Works across most notebook variants

This approach is general, clean and lightweight but involves spawning a full HTTP server with: - Multi-user capabilities (unnecessary for notebooks) - Async architecture (notebooks are sync) - Complex lifecycle management - Production-level features (overkill for notebook use) - IPython Javascript display object in each cell to trigger HTMX.

The Bridget Solution

Bridget proposes using a widget-based approach that: 1. Simplifies: Replaces HTTP server with direct widget communication 2. Generalizes: Works across notebook environments via AnyWidget 3. Extends: Enables creation of notebook-specific components 4. Integrates: Provides Python API for HTMX functionality

This proof-of-concept shows how we can maintain FastHTML’s powerful features while better adapting to the notebook environment’s unique characteristics and constraints.

Note

Personal note: My interest here isn’t web apps, but notebook development.

I like ipywidgets, or at least the intention. I’ve written several and used them in many personal projects. However, they’re a challenging piece of software with complicated tooling. They inherit all the nightmarish complexity of the JavaScript ecosystem, where the tooling is more involved than the language itself. AnyWidget is a step in the right direction, liberating us Pythonistas from the JS ecosystem. But the notebook part, the Python part, remains an unsolved problem in my opinion.

Of all solutions I’ve explored for achieving full interactivity in notebooks, HTMX + FastHTML comes closest, feeling more natural and integrating better with the notebook environment.

bridge_cfg.auto_show = True
bridge_cfg.auto_id = True

These flags control automatic behaviors throughout this notebook. See 01_helpers.ipynb for full configuration options.

Helpers

Some convenience utils to work with FastHTML in Notebooks.

# nb = get_nb(show_logger=True, show_feedback=True, summary=True)
# bridge = get_bridge(plugins=[HTMXCommanderPlugin()], wait=5)
bridge = get_bridge(plugins=[HTMXCommanderPlugin(), NBHooksPlugin()], show_logger=True, summary=True, wait=5)

Bridget automatically loads required JavaScript libraries:

  1. HTMX
  2. FastHTML core scripts
  3. Awesome gnat’s Scope and Surreal scripts
  4. Bridge helpers

These are loaded by default when creating a Bridget app. You can customize this by overriding defaults when creating the app (covered in app creation section)

ClientP


source

ClientP

 ClientP (*args, **kwargs)

HTTP client interface supporting REST operations (get, post, delete)

Bridget utils


source

request2httpx_request

 request2httpx_request (cli:httpx.AsyncClient,
                        http_request:dict[str,typing.Any])

Convert bridget request dict to httpx Request object


source

httpx_response_to_json

 httpx_response_to_json (response:httpx.Response)

Convert httpx Response to JSON dict with headers and content


source

request2response

 request2response (cli:httpx.AsyncClient, http_request)

Execute bridget request and return httpx Response


source

HasHTML

 HasHTML (*args, **kwargs)

Objects that can render themselves as HTML strings


source

HasFT

 HasFT (*args, **kwargs)

Objects that can convert themselves to FastHTML FT components

@FC.patch
def _ipython_display_(self: Response):
    # dhdl = DisplayId()
    # dhdl.display(self.text)
    IDISPLAY(HTML(self.text))

BridgetClient

A mixin class that wraps FastHTML and Client functionality for displaying and mounting components.

Display: - FT & objects with __ft__ or __html__ - Mappings in the form of HTTP requests - URLs

Mount: - APIRouter or RouteProvider


source

BridgetClient

 BridgetClient ()

A simple wrapper around FastHTML and Client.

BridgeClient is a mixin class that provides:

  1. Client functionality:
  2. Display functionality: Renders FastHTML objects and route responses in notebook cells
  3. Component mounting: Helper for mounting route providers

It serves as a mixin class for Bridget, which provides the full HTMX integration.

The class handles different types of “bridgeable” content: - FastHTML objects (FT) - Objects with __ft__ or __html__ methods - Route paths - Request mappings

brt_cli = BridgetClient().setup()
app, rt = brt_cli.app, brt_cli.app.route

brt_cli();
404 Not Found
@app.get("/")  # type: ignore
def home():
    # return "<strong>Config</strong>: " + str(bridge_cfg.as_dict())
    # return Span(Strong('Config'), ': ' + str(bridge_cfg.as_dict()))
    return DetailsJSON(bridge_cfg.as_dict(), summary='Bridget Config').__ft__()

brt_cli();
@rt("/hi")
def get():
    return 'Hi there'

http_request = {
    'upload': {},
    'headers': {
        'HX-Request': 'true',
        'HX-Current-URL': 'vscode-webview://1ql27b...er'
    },
    'headerNames': {'hx-request': 'HX-Request', 'hx-current-url': 'HX-Current-URL'},
    'status': 0,
    'method': 'GET',
    'url': '/hi',
    'async': True,
    'timeout': 0,
    'withCredentials': False,
    'body': None
}

response = brt_cli._response(http_request)
test_eq(response.status_code, 200)
test_eq(response.text, 'Hi there')

console.print_json(data=(json_resp := httpx_response_to_json(response)))
test_eq(json_resp['status'], 200)
test_eq(json_resp['data'], 'Hi there')
test_eq(json_resp['headers']['content-length'], '8')

response
{
  "headers": {
    "vary": "HX-Request, HX-History-Restore-Request",
    "content-length": "8",
    "content-type": "text/html; charset=utf-8",
    "last-modified": "Wed, 03 Dec 2025 14:22:36 GMT",
    "cache-control": "no-store, no-cache, must-revalidate"
  },
  "status": 200,
  "statusText": "OK",
  "data": "Hi there",
  "xml": null,
  "finalUrl": "http://nb/hi"
}
Hi there
brt_cli(http_request);
Hi there
class Buttons:
    def __ft__(self):
        return (
            Button(garlic=True, hx_get='/test', hx_select='button[vampire]', hx_swap='afterend')(_n,
                Style(self._css_.format('hsl(264 80% 47%)', 'hsl(264 80% 60%)')),
                'garlic ', Span('🧄', cls='icon'),
            _n), _n,
            Button(vampire=True, hx_get='/test', hx_select='button[garlic]', hx_swap='afterend')(_n,
                Style(self._css_.format('hsl(150 80% 47%)', 'hsl(150 80% 60%)')), 
                'vampire ', Span('🧛', cls='icon'),
            _n), _n,
        )
    _css_ = '''
    me {{ margin: 4px; padding: 10px 30px; min-width: 80px; background: {0}; border-bottom: 0.5rem solid hsl(264 80% 20%); }}
    me {{ color: antiquewhite; font-size: 14pt; font-variant: all-small-caps; font-weight: bold; }}
    me:hover {{ background: {1}; }}
    me span.icon {{ font-size:16pt; }}
'''
@rt("/test")
def get():
    return Buttons()

http_request = {
    'headers': {'hx-request': '1'},
    'method': 'GET',
    'url': 'http://nb/test',
}

r = brt_cli._response(http_request)
display(Markdown(f"```HTML\n{r.text}\n```"))
r
<button garlic hx-get="/test" hx-select="button[vampire]" hx-swap="afterend">
   <style>
    me { margin: 4px; padding: 10px 30px; min-width: 80px; background: hsl(264 80% 47%); border-bottom: 0.5rem solid hsl(264 80% 20%); }
    me { color: antiquewhite; font-size: 14pt; font-variant: all-small-caps; font-weight: bold; }
    me:hover { background: hsl(264 80% 60%); }
    me span.icon { font-size:16pt; }
</style>
garlic <span class="icon">🧄</span>
</button>
<button vampire hx-get="/test" hx-select="button[garlic]" hx-swap="afterend">
   <style>
    me { margin: 4px; padding: 10px 30px; min-width: 80px; background: hsl(150 80% 47%); border-bottom: 0.5rem solid hsl(264 80% 20%); }
    me { color: antiquewhite; font-size: 14pt; font-variant: all-small-caps; font-weight: bold; }
    me:hover { background: hsl(150 80% 60%); }
    me span.icon { font-size:16pt; }
</style>
vampire <span class="icon">🧛</span>
</button>
brt_cli('/test');
brt_cli(Buttons());
# css = Style(':root {--pico-font-size:90%,--pico-font-family: Pacifico, cursive;}')

@app.route("/page")
def get():
    return (Title("Hello World"), 
            Main(H1('Hello, World'), cls="container"))

r = brt_cli._response(req := {
    # 'headers': {'hx-request': '1'},
    'method': 'GET',
    'url': 'http://nb/page',
})
display(Markdown(f"```HTML\n{r.text}\n```"))
brt_cli('page');
 <!doctype html>
 <html>
   <head>
     <title>Hello World</title>
     <link rel="canonical" href="http://nb/page">
<script>
    function sendmsg() {
        window.parent.postMessage({height: document.documentElement.offsetHeight}, '*');
    }
    window.onload = function() {
        sendmsg();
        document.body.addEventListener('htmx:afterSettle',    sendmsg);
        document.body.addEventListener('htmx:wsAfterMessage', sendmsg);
    };</script>   </head>
   <body>
<main class="container">       <h1>Hello, World</h1>
</main>   </body>
 </html>
Hello World

Hello, World

dtl = Details(cls='pale', open=True)(
    Style('me details { border: 1px solid #aaa; padding: 0.5em 0.5em 0; } me summary { font-weight: bold; margin: -0.5em -0.5em 0; padding: 0.5em; } me pre { margin: 0; }'),
    Summary('What Lucy get?'),
    Div(cls='contents', style='display: flex; flex-direction: column;')(
        Pre('She'),
        Pre('got'),
        Pre('diamonds!')
    )
)
display(dtl)
brt_cli(dtl);
What Lucy get?
She
got
diamonds!
What Lucy get?
She
got
diamonds!
def details_ft(
        *contents, 
        summary:str|None=None, 
        closed:bool=False, 
        direction:str='column', 
        height:str|None=None,
        contents_style:str='', item_style:str=''):
    style = f"display: flex; flex-direction: {direction};{height or ''}{contents_style or ''}"
    return Details(cls='pale', open=not closed)(
        Summary(summary),
        Div(cls='contents', style=style)(
            *(Div(style=item_style)(_) for _ in contents)
        )
    )

dtl = details_ft(
        *(Pre(_) for _ in ('She', 'got', 'diamonds')), 
        summary='What Lucy get?')
display(dtl)
brt_cli(dtl);
What Lucy get?
She
got
diamonds
What Lucy get?
She
got
diamonds
bridge.logger.show()

As there only one instance of a logger running, use logger.show() to display the logger near a cell to see what’s going on.

<div id="output-99">Original</div>
Original
bridge.commander.swap('#output-99', '<div>Swapped!</div>', swapStyle='innerHTML')

We can also use HTMX API through Bridge (documented in 16_bridge_plugins.ipynb).

But we’ll see a more convenient way with Bridget, without peppering the notebook of IPython Javascript display objects.

# fbridge(Div('Hey, Foo!'), display_id='ultra-cool-display-id')
Div('Hey, Foo!')
Hey, Bar!
# bridge(Div('Hey, Bar!'), display_id=did, update=True)
dh = bridge.nbhooks.brdd.dh
brt_cli(Div('Hey, Bar!'), display_id=dh)

Use display_id and update kwargs to modify or update an existing output cell.

Bridget plugin

A specialization of Bridge that connects HTMX with FastHTML in notebooks.


source

get_bridget

 get_bridget (app=None, logger:NBLogger|None=None, show_logger:bool=False,
              lnks:dict[str,FT]|None=None,
              esms:dict[str,str|Path]|None=None,
              plugins:Sequence[BridgePlugin]|None=None,
              kwplugins:dict[str,str]|None=None, wait:int=0,
              summary:bool=False, factory:Callable[...,Any]|None=None,
              timeout:float=10, sleep:float=0.2, n:int=10,
              show:Callable[[bool],None]|None=None, **kwargs)
Type Default Details
app NoneType None
logger NBLogger | None None
show_logger bool False
lnks dict[str, FT] | None None
esms dict[str, str | Path] | None None
plugins Sequence[BridgePlugin] | None None
kwplugins dict[str, str] | None None
wait int 0
summary bool False
factory Callable[…, Any] | None None
timeout float 10
sleep float 0.2
n int 10
show Callable[[bool], None] | None None
kwargs VAR_KEYWORD
Returns Bridget type: ignore

source

Bridget

 Bridget (app:fasthtml.core.FastHTML|None=None, *args, **kwargs)

Inherit from this to have all attr accesses in self._xtra passed down to self.default

Bridget is a specialized widget that: 1. Inherits from BridgeBase for FastHTML/display functionality 2. Uses AnyWidget tooling-free, general widget solution, for browser-kernel communication 3. Implements a singleton pattern to ensure one instance per notebook

Attributes: - _esm: Path to bundled JavaScript module - request/response: Traitlets for HTMX communication - htmx/htmx_sels: Configuration for HTMX integration

The core part is using the widget’s bidirectional communication to replace HTMX’s HTTP transport: 1. Browser: HTMX makes requests thinking it’s talking to a server 2. Widget: Captures these requests via traitlets 3. Kernel: Processes requests using FastHTML routing 4. Widget: Returns responses that HTMX understands

Bridget JavaScript Implementation bridget.js

Bridget’s JavaScript code provides the browser-side implementation that: 1. Intercepts HTMX AJAX requests (SSE, WS in the future) 2. Routes them through the widget’s communication channel 3. Processes responses back to HTMX 4. Manages HTMX initialization in notebook output cells

Some details

Request Flow

  1. HTMX makes an AJAX request
  2. xhook intercepts it via on_request
  3. Request is serialized and sent to kernel via traitlets
  4. Kernel processes request and sends response
  5. Response is processed by response_changed
  6. HTMX receives response and updates DOM

HTMX Integration

  1. BridgetObserver watches for new notebook output cells
  2. New cells are processed with htmx.process()
  3. HTMX attributes become active
  4. AJAX requests are intercepted and routed through widget

Note that Bridget JS is backend agnostic. We’re using FastHTML here because its the best for my quest of replacing ipywidgets as the main form of interactivity in a notebook, but it could be any other backend libarary.

get_app

Helper function to initialize the root-level app, bridget, and route.


source

get_app

 get_app (hooks=False, nb=False, cfg:Mapping|None=None,
          logger:NBLogger|None=None, show_logger:bool=False,
          lnks:dict[str,FT]|None=None, esms:dict[str,str|Path]|None=None,
          plugins:Sequence[BridgePlugin]|None=None,
          kwplugins:dict[str,str]|None=None, wait:int=0,
          summary:bool=False, factory:Callable[...,Any]|None=None,
          timeout:float=10, sleep:float=0.2, n:int=10,
          show:Callable[[bool],None]|None=None, **kwargs)
Type Default Details
hooks bool False
nb bool False
cfg Mapping | None None
logger NBLogger | None None
show_logger bool False
lnks dict[str, FT] | None None
esms dict[str, str | Path] | None None
plugins Sequence[BridgePlugin] | None None
kwplugins dict[str, str] | None None
wait int 0
summary bool False
factory Callable[…, Any] | None None
timeout float 10
sleep float 0.2
n int 10
show Callable[[bool], None] | None None
kwargs VAR_KEYWORD
Returns tuple[FastHTML, Bridget, MethodType] type: ignore
app, brt, rt = get_app(show_logger=True)
test_is(brt.app, app)

Let’s see Bridget in action.

def counter():
    n = 0
    @rt('/inc')
    def increment():
        nonlocal n
        n += 1
        return f"{n}"
    return Div()(
        Button(hx_get='/inc', hx_target='find span', hx_swap='textContent')(
            'Count: ', Span(f"{n}")))

counter()
# req = { 
#     "headers": { "HX-Request": "true", "HX-Current-URL": "vscode-webview://1ql27b0grt14rkuj26idbt9ktekivp44iu2s8pgfndvblm2j3iep/index.html?id=3d1cf833-fbc2-442d-885b-05f7dee12052&origin=1e95bb6a-280d-4f6e-8090-7f55f54cbfbd&swVersion=4&extensionId=&platform=electron&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&parentOrigin=vscode-file%3A%2F%2Fvscode-app&purpose=notebookRenderer" }, 
#     "headerNames": { "hx-request": "HX-Request", "hx-current-url": "HX-Current-URL" }, 
#     "status": 0, "method": "GET", "url": "/inc", "async": True, "timeout": 0, "withCredentials": False, 
#     "body": None, 
#     "req_id": "652540cd-1ebe-4983-8701-88074dfe6328" }
# bridge._message_hdlr(None, {'ctx': 'bridget', 'kind': 'request', 'request': req}, None)
def random_hsl(saturation=50, lightness=90):
    hue = random.randint(0, 360)
    return f"hsl({hue} {saturation}% {lightness}%)"

def counter(n=0):
    @rt('/inc')
    def increment(n:int):
        return Button(f"Count: {n+1}", value=f"{n+1}", name='n', 
            hx_post='/inc', hx_swap='outerHTML', 
            style=f"background-color:{random_hsl()}; font-weight: bold")
    return increment(n-1)

counter()
@rt("/test")
def get():
    return Buttons()


html = Div()(
    H2('HTMX Test'),
    Div('Swapped DOM elements are styled instantly when they arrive.'),
    Buttons(),
)

# brt(html); # or simply, if bridge_cfg.auto_show is True:
html

HTMX Test

Swapped DOM elements are styled instantly when they arrive.

An example from gnat’s css-scope-inline. Click the buttons.

See 40_details_json.ipynb for a lazy JSON browser.

TipWhy are my output cells un-styled and/or inactive?

Because all changes made with Bridge/Bridget/FastHTML/HTMX are transient.

Let’s talk about what’s happening under the hood:

We’re modifying the DOM - the HTML structure in your browser - at runtime. This is not a notebook editor; it’s a runtime tool. For anything to work, you need to execute the cells.

Here’s the thing: a notebook is just JSON. Ultimately, output cells are just HTML (derived from any IPython displayable object), environments like VSCode won’t render them until display time.

When you open a notebook, tjhe front-end usually takes a lazy approach (VSCode/Cursor more than others): - It renders existing outputs from the JSON (including JavaScript) - But it won’t execute any cells automatically - Even ipywidgets get special (and sometimes quirky) treatment

Think of the notebook’s JSON as a snapshot from when you ran the cells. Any DOM changes we make don’t get saved back to this JSON.

This means: 1. Bridge needs explicit initialization to load its JS/CSS 2. On load, saved notebooks will execute the JavaScript/HTML put there by cell runs 3. Kernel-side code won’t run until you execute the cells

Could we make Bridget modify the actual notebook outputs? Technically, yes, maybe. But given the labyrinthine complexity of the Jupyter ecosystem (and the mountains of JavaScript involved), I get a headache just thinking about it. Some mountains are better left unclimbed! 😅

Simple widget

class BWidget(T.HasTraits, RouteProvider):
    bridget: Bridget = None  # type: ignore
    _mounted = False
    def __ft__(self): ...
    def _ipython_display_(self):
        brt = get_bridget()
        if bridge_cfg.auto_mount and not self._mounted: brt.mount(self, show=False)
        brt(self);
class BValue(BWidget):
    value=T.CInt(0).tag(sync=True)
    _updating = False
    
    def wrapper_id(self): return self.ar.name().replace(':', '_')

    @contextmanager
    def _update_ctx(self):
        self._updating = True
        yield
        self._updating = False

    @T.observe('value')
    def on_value(self, _):
        if self.bridget and not self._updating:
            self.bridget.bridge.commander.swap(f"#{self.wrapper_id()}", to_xml(self.__ft__()), swapStyle='innerHTML')

    @ar.post('/value')  # type: ignore
    def changed(self, value:int):
        with self._update_ctx(): self.value = value
        return str(value)
@dataclasses.dataclass
class BIntSlider(BValue):
    min:int=0; max:int=100; step:int=1; readout:bool=True; readout_format:str='d'
    
    def __ft__(self):
        if bridge_cfg.auto_mount and not self._mounted: get_bridget().mount(self, show=False)
        return Div(id=self.wrapper_id(), cls='bridget slider')(
            Label(_for='value')('Scale'), _n,
            Input(type='range', name='value', min=self.min, max=self.max, step=self.step, value=self.value,
                # hx_post=f"{self.ar.to()}{self.ar.to('changed')}", hx_trigger='input changed', 
                hx_post=self.ar.to('changed'), hx_trigger='input changed', 
                hx_target='next text', hx_swap='textContent'),
            Text(id='spanscale', style='inline')(self.value), _n
        )

app.routes.clear()

bridge_cfg.auto_mount = True

rp = BIntSlider()
rp
0
rp.value = 77
sld2 = BIntSlider(step=2)
sld2
0
with bridge_cfg(auto_mount=True):
    sld3 = BIntSlider()
    sld4 = BIntSlider()
    # brt.mount(sld3, show=False)
    # brt.mount(sld4, show=False)
    T.link((sld3, 'value'), (sld4, 'value'))

(box := Div(style='display: flex; gap: 1em;')(sld3, sld4))
0
0
sld3.value = 22
test_eq(sld4.value, 22)

Hydrate (TBD)

Can we edit .ipynbs directly to capture actual output without nbformat or editing the JSON in disk?

# def hydrate(bridget=True, app: FastHTML | None=None, appkw:dict[str, Any]={}, **kwargs): 
#     app, bridge, rt = get_app(True, app, appkw=appkw, **kwargs)
#     bridget = Bridget(bridge) if bridget else None
#     return app, bridge, rt, bridget

# hydrate()

What about WebSockets and SSE Support?

While HTMX supports WebSockets and Server-Sent Events (SSE) through extensions(1), this proof-of-concept focuses only on AJAX functionality for several reasons:

  1. Core Functionality: HTMX is primarily an AJAX framework - WS and SSE support are add-ons with simpler implementations(2)

  2. Proof of Concept: For demonstrating the viability of using HTMX in notebooks, AJAX support is sufficient

  3. Future Extension: Adding WS/SSE support would be straightforward since:

    • The notebook Comm layer already uses WebSockets
    • HTMX’s extension system is well-documented(3)
    • The transport layer replacement pattern is already established with AJAX