Anywidget Front-End Module (AFM)
What is AFM?
The Anywidget Front-End Module (AFM) specification defines a standard for creating portable widget front-end code. Our vision is to enable widget reuse within and beyond Jupyter, including other computational notebooks and standalone web applications. AFM is oriented around a minimal set of APIs we identified as essential for integration with host platforms, boiling down to:
- Bidirectional communication with a host (e.g., Jupyter)
- Modifying output areas (DOM manipulation) (e.g., a notebook output cell)
Conformance
The key words MUST, MUST NOT, SHOULD, and MAY in this document are to be interpreted as described in RFC 2119 when, and only when, they appear in all capitals.
This document describes AFM as of anywidget 0.11. Prior revisions did not
include the signal, exports, or host
primitives. Those additions are backward compatible: an AFM authored against
an earlier revision continues to be a valid AFM under this revision.
Core Concepts
Front-End Module
The Anywidget Front-end Module is widget front-end code authored by a widget developer. It contains the front-end logic of a widget, defined by implementing lifecycle hooks that control the widget’s behavior. AFM is a web-standard ECMAScript module (ESM) that can be authored as a plain text file or generated from a more complex front-end toolchain.
Host platform
The web-based environment in which a widget is embedded. It is responsible for loading AFM modules and calling their lifecycle hooks with the required platform APIs.
The anywidget Python library provides the glue code to make any Jupyter-like
environment (Jupyter Notebook, JupyterLab, Google Colab, VS Code) an
AFM-compatible host platform. The
marimo project is an example of a
native host platform.
A consolidated normative checklist for host implementors is at Host requirements.
Module shape
An Anywidget Front-End Module is an ECMAScript module that defines a widget’s behavior through lifecycle hooks.
export default {
initialize({ model, signal }) {
// Set up shared state, event handlers, or programmatic exports.
// Use `signal` (AbortSignal) for cleanup when the widget is destroyed.
},
render({ model, el, signal, host }) {
// Render the widget's view into the `el` HTMLElement.
// Use `signal` for view cleanup; use `host` to resolve child widgets.
},
};
Both hooks MAY be async. Hosts MUST await each hook before treating the
corresponding lifecycle phase as complete (see Ordering).
The default export MAY also be a function (the factory form) that returns
this interface. The factory runs once per widget instance before initialize.
State captured in its closure is shared between initialize and all
subsequent render calls:
export default async () => {
let extraState = {};
return {
initialize({ model, signal }) {
/* ... */
},
render({ model, el, signal, host }) {
/* ... */
},
};
};
Lifecycle
The AFM lifecycle follows a Model-View pattern with two phases:
- Model initialization: occurs once when a widget is first created, setting
up the model and any shared state. Runs
initialize. - View rendering: occurs each time a widget is displayed (potentially
multiple times for a single widget instance). Runs
render.
Ordering
For a given widget instance, initialize MUST complete before any view is
rendered. Hosts MUST await the result of initialize (including any
returned promise) before calling render for that widget. Multiple views MAY
be rendered concurrently and share the same model and initialize-time state.
initialize
initialize(props: {
model: AnyModel;
signal: AbortSignal;
}): Awaitable<void | (() => Awaitable<void>) | object>;
Executed once per widget instance. Receives:
model: the model interface.signal: anAbortSignalthe host MUST abort when the widget is destroyed.
initialize MAY return one of three shapes. The host distinguishes them via
typeof:
| Return value | Interpretation |
|---|---|
void | No cleanup, no exports. |
() => void | Cleanup callback. Hosts MUST run it when signal aborts. |
object | Widget exports. Use signal for cleanup. |
render
render(props: {
model: AnyModel;
el: HTMLElement;
signal: AbortSignal;
host: Host;
}): Awaitable<void | (() => Awaitable<void>)>;
Executed once per view. Receives:
model: the model interface.el: anHTMLElementto render into.signal: anAbortSignalthe host MUST abort when the view is removed.host: aHostfor resolving child widgets.
render MAY return a cleanup function. Hosts MUST run it when signal aborts.
Cleanup
New code SHOULD prefer signal over a returned callback. signal composes
with web platform APIs that already accept an AbortSignal
(addEventListener, fetch):
export default {
render({ model, el, signal }) {
el.addEventListener(
"click",
() => {
/* ... */
},
{ signal },
);
let onChange = () => {
/* ... */
};
model.on("change:value", onChange);
signal.addEventListener("abort", () => model.off("change:value", onChange));
},
};
Returned cleanup callbacks remain supported. Hosts MUST wire any returned
callback to run when the corresponding signal aborts; a returned callback
and the signal are not two independent cleanup channels. Calling
signal.aborted after a hook returns MUST observe the same aborted state
that triggers the cleanup.
Errors
If a lifecycle hook throws (or its returned promise rejects), the host MUST:
- Treat the hook’s phase as failed and not advance to the next phase.
- Abort the corresponding
signal(which runs any cleanup wired through it). - Surface the error on the host’s diagnostic channel.
A widget whose initialize failed SHOULD be considered unusable; subsequent
attempts to render its view SHOULD fail visibly rather than retry silently.
Hot module replacement
A host MAY support hot module replacement (HMR), in which the widget’s source module is replaced at runtime without destroying the model. When HMR occurs the host MUST:
- Abort the previous
initialize’ssignal(which transitively aborts every viewsignalderived from it). - Load the replacement module.
- Run the new module’s
initialize. - Re-render any active views with the new
render.
This sequence preserves model state across HMR while ensuring the previous module’s cleanup runs before any new code touches the widget.
Model interface
The model interface in AFM is loosely based on traditional Jupyter Widgets
but defines a narrower subset of APIs.
This approach maintains familiarity for widget developers while requiring host
platforms to implement only a small subset of APIs to be a proper host.
/**
* The model interface for an Anywidget Front-End Module
* @see {https://github.com/manzt/anywidget/tree/main/packages/types} for complete types
*/
interface AnyModel {
/** Get a property value from the model */
get(key: string): any;
/** Set a property value in the model */
set(key: string, value: any): void;
/** Remove an event listener */
off(eventName?: string | null, callback?: Function | null): void;
/** Listen for custom messages from the host */
on(eventName: "msg:custom", callback: (msg: any, buffers: DataView[]) => void): void;
/** Listen for property changes (callback receives no arguments) */
on(eventName: `change:${string}`, callback: () => void): void;
/** Commit any pending changes to the host */
save_changes(): void;
/** Send a custom message to the host */
send(content: any, callbacks?: any, buffers?: ArrayBuffer[] | ArrayBufferView[]): void;
}
change: callback signature. The change: event callback takes no
arguments. To read the current value within a callback, use model.get():
model.on("change:count", () => {
let count = model.get("count");
console.log("count changed to", count);
});
Some host platforms (e.g., Jupyter via Backbone.js) may pass extra arguments to the callback as a side effect of their underlying framework. Those extra arguments are not part of AFM. Widget authors MUST NOT rely on them.
experimental.invoke. Some hosts expose an additional experimental prop
on initialize and render that includes an invoke(name, msg, opts) method
for issuing typed RPC-style messages to the host. This surface is provider-
specific and is not part of the AFM specification at this revision.
This interface can be implemented without dependencies and does not require
extending Jupyter Widget’s patch of BackboneJS.
For instance, marimo’s model implementation uses
no third-party dependencies.
Widget composition
A widget MAY render and interact with other widgets on the same page. The
host prop on render exposes two methods for resolving a
widget reference into a usable handle.
Widget references
A widget reference is a string of the form "anywidget:<model_id>". References
MAY appear at any position in synced state: as the value of a top-level trait,
inside a list, or inside a dict. The host MUST be able to recover the
referenced model from the trailing model_id portion of the string.
How references end up in synced state is the responsibility of whatever
serialization layer writes the wire data, not of AFM itself. Hosts that
integrate with the anywidget Python package can rely on it to auto-detect
Python objects exposing a model_id attribute (or implementing the anywidget
descriptor protocol via MimeBundleDescriptor) and emit reference strings
on their behalf.
host.getWidget / host.getModel
interface Host {
getWidget<T = unknown>(
ref: string,
): Promise<{
exports: T;
render(opts: { el: HTMLElement; signal?: AbortSignal }): Promise<void>;
}>;
getModel<T = unknown>(ref: string): Promise<AnyModel<T>>;
}
host.getWidget(ref):
- Awaits the child’s
initializebefore resolving. - Returns a handle exposing the child’s exports and a
renderfunction bound to that child’s view.
host.getModel(ref):
- Returns the child’s underlying
AnyModelfor direct event subscriptions orget/set/sendaccess without participating in rendering.
host is available on render only. It is NOT provided to initialize. This
restriction prevents parent/child initialize ordering hazards (a parent
attempting to resolve a child whose own initialize has not yet started).
The child’s render SHOULD receive the parent’s signal so that aborting the
parent’s view tears the child’s view down too.
Example
class Dashboard(anywidget.AnyWidget):
_esm = "dashboard.js"
control = anywidget.WidgetTrait().tag(sync=True)
Dashboard(control=Slider(value=50))
// slider.js
export default {
initialize({ model }) {
return {
getValue: () => model.get("value"),
onChange: (cb) => model.on("change:value", cb),
};
},
render({ model, el, signal }) {
/* ...build a slider DOM element, wire signal cleanup... */
},
};
// dashboard.js
export default {
async render({ model, el, signal, host }) {
let slider = await host.getWidget(model.get("control"));
if (typeof slider.exports?.onChange === "function") {
slider.exports.onChange(() =>
console.log("value:", slider.exports.getValue()),
);
}
let div = document.createElement("div");
el.appendChild(div);
await slider.render({ el: div, signal });
},
};
Exports
initialize MAY return an object, which the host stores and exposes as
exports on the handle returned by host.getWidget. AFM does not impose any
schema on exports. Widget authors define their own interfaces and consumers
duck-type at the boundary.
If a widget’s initialize returns nothing (or returns a cleanup function),
exports MUST be undefined on the resolved handle.
Slot reassignment
When a widget-valued trait is reassigned at runtime (e.g., a Python parent
sets dashboard.control = different_slider), the parent’s
change:<trait> event fires with the new reference string. The parent
SHOULD re-resolve the new reference via host.getWidget and tear down any
child views it previously rendered. The simplest pattern is to derive a
per-resolution AbortController from the parent’s signal and abort it on
each change: event:
async render({ model, el, signal, host }) {
let current = new AbortController();
signal.addEventListener("abort", () => current.abort());
let mount = async () => {
current.abort();
current = new AbortController();
let combined = AbortSignal.any([signal, current.signal]);
let child = await host.getWidget(model.get("control"));
let div = Object.assign(document.createElement("div"), { /* ... */ });
el.replaceChildren(div);
await child.render({ el: div, signal: combined });
};
await mount();
model.on("change:control", mount);
}
Errors
Hosts MUST surface composition failures with descriptive errors rather than silently broken handles:
- Malformed refs:
host.getWidgetandhost.getModelMUST reject when given a value that is not a recognized reference string. The rejection SHOULD include the offending value. - Unknown model: when the model_id in a reference does not resolve to a known model, both methods MUST reject with an error naming the unresolved id.
- Stalled
initialize: hosts SHOULD apply a timeout tohost.getWidgetand reject if the child’sinitializedoes not complete within a reasonable time. (The reference implementation uses 10 seconds.)
Hosts that do not implement composition SHOULD still expose host on render
and have its methods reject with a descriptive error. Omitting host
entirely changes the prop signature seen by widget code; rejecting from
getWidget keeps the signature uniform and surfaces the limitation cleanly.
Circular references
host.getWidget(A) from inside B’s render and host.getWidget(B) from
inside A’s render will deadlock, since each parent waits for the other’s
initialize to complete before its own can proceed. Circular composition
chains are not supported.
Host requirements
Consolidated normative checklist for an AFM-compatible host implementation.
A host MUST:
- Load AFM modules as web-standard ECMAScript modules.
- Implement the model interface for each widget instance.
- Run
initializeonce per instance, awaiting its result before anyrendercall for that instance. - Run
renderonce per view, providingmodel,el, anAbortSignal(signal), and aHost(host). - Abort the supplied
signalwhen the corresponding lifecycle ends (widget destroyed forinitialize, view removed forrender). - Run any cleanup function returned from a lifecycle hook when that hook’s
signalaborts. The returned callback and the signal MUST NOT be treated as two independent cleanup channels. - Maintain a registry of widget bindings keyed by model, so that
widget references can be resolved into their
exportsand viewrender. - Resolve widget references (
"anywidget:<model_id>") passed tohost.getWidget/host.getModel. Reject with descriptive errors on malformed refs, unknown models, or stalled childinitialize(see Composition errors). - Cascade view teardown: descendant views rendered with a parent’s
signalSHOULD tear down when that parent signal aborts.
A host SHOULD:
- Surface lifecycle hook errors on a diagnostic channel rather than swallowing them.
- Apply a timeout to
host.getWidgetto avoid hung promises when a child’sinitializenever resolves.
A host MAY:
- Support hot module replacement for the widget’s source module.
- Expose additional, host-specific surfaces (e.g.,
experimental.invoke). These are outside the AFM specification at this revision and MUST NOT collide with documented prop names.
Framework Bridges
AFM intentionally does not prescribe specific models for state management or UI rendering. While many front-end tools exist to help with authoring UIs (e.g., React, Svelte, Vue) we strongly believe that incorporating these non-web-standard pieces at the specification level would be a mistake. Our goal is to create a solution for reusable widgets that aligns with the web’s strong backwards compatibility guarantees.
Instead of baking framework support into the specification, we envision support for UI frameworks through:
- Framework bridges: libraries that provide idiomatic APIs for popular frameworks while adhering to the AFM specification.
- Developer tooling: simple build processes that compile framework-specific code into standard AFM.
This approach lets anywidget developers use their preferred tools and frameworks while ensuring the final output is web-standard JavaScript.
For example, using the @anywidget/react bridge looks like this:
// index.jsx
import * as React from "react";
import { useModelState, createRender } from "@anywidget/react";
function Counter() {
let [count, setCount] = useModelState("count");
return <button onClick={() => setCount(count + 1)}>Count is {count}</button>;
}
export default {
render: createRender(Counter),
};
The bridge provides an idiomatic
hook for model state
(useModelState); createRender wraps a React component so it adheres to
the AFM specification.
By keeping framework support outside the core specification, AFM stays flexible, future-proof, and aligned with the long-term evolution of web standards.