diff --git a/src/packages/frontend/components/icon.tsx b/src/packages/frontend/components/icon.tsx
index 1fd6705436..fe58077824 100644
--- a/src/packages/frontend/components/icon.tsx
+++ b/src/packages/frontend/components/icon.tsx
@@ -296,6 +296,7 @@ const IconSpec = {
   fire: FireOutlined,
   firefox: { IconFont: "firefox" },
   flash: ThunderboltOutlined,
+  "floppy-o": SaveOutlined, // used by matplotlib widget
   "flow-chart": { IconFont: "flow-chart" },
   folder: FolderOutlined,
   "folder-open": FolderOpenOutlined,
@@ -649,10 +650,23 @@ if (typeof $ != "undefined") {
       // @ts-ignore
       const that = $(this);
       for (const elt of that.find(".fa")) {
+        let style: CSS | undefined = undefined;
+        if (elt.className.includes("fa-fw")) {
+          // could break if subset of some other icon name, but doesn't seem to be.
+          // this is "fixed width" in font awesome:
+          style = { width: "1.28571429em", textAlign: "center" };
+        }
         for (const cls of elt.className.split(/\s+/)) {
+          if (cls == "fa-fw") {
+            continue;
+          }
           if (cls.startsWith("fa-")) {
             ReactDOM.render(
-              <Icon name={cls.slice(3)} spin={cls == "fa-cocalc-ring"} />,
+              <Icon
+                style={style}
+                name={cls.slice(3)}
+                spin={cls == "fa-cocalc-ring"}
+              />,
               elt
             );
             break;
diff --git a/src/packages/frontend/jupyter/project-actions.ts b/src/packages/frontend/jupyter/project-actions.ts
index ee4a3d5dea..1983c83fd8 100644
--- a/src/packages/frontend/jupyter/project-actions.ts
+++ b/src/packages/frontend/jupyter/project-actions.ts
@@ -124,7 +124,7 @@ export class JupyterActions extends JupyterActions0 {
       5;
       cells = cells.toJS();
     }
-    dbg(`cells at manage_init = ${JSON.stringify(cells)}`);
+    //dbg(`cells at manage_init = ${JSON.stringify(cells)}`);
 
     this.sync_exec_state = underscore.debounce(this.sync_exec_state, 2000);
     this._throttled_ensure_positions_are_unique = underscore.debounce(
@@ -400,13 +400,13 @@ export class JupyterActions extends JupyterActions0 {
   //    Only one client -- the project itself -- will run this code.
   manager_on_cell_change = (id: any, new_cell: any, old_cell: any) => {
     const dbg = this.dbg(`manager_on_cell_change(id='${id}')`);
-    dbg(
-      `new_cell='${misc.to_json(
-        new_cell != null ? new_cell.toJS() : undefined
-      )}',old_cell='${misc.to_json(
-        old_cell != null ? old_cell.toJS() : undefined
-      )}')`
-    );
+//     dbg(
+//       `new_cell='${misc.to_json(
+//         new_cell != null ? new_cell.toJS() : undefined
+//       )}',old_cell='${misc.to_json(
+//         old_cell != null ? old_cell.toJS() : undefined
+//       )}')`
+//     );
 
     if (
       (new_cell != null ? new_cell.get("state") : undefined) === "start" &&
@@ -1173,6 +1173,13 @@ export class JupyterActions extends JupyterActions0 {
           data
         );
         */
+      } else if (type == "message_to_kernel") {
+        const content =
+          this.syncdb.ipywidgets_state.getLastMessageToKernel(model_id);
+        this.jupyter_kernel.send_comm_message_to_kernel(misc.uuid(), model_id, {
+          method: "custom",
+          content,
+        });
       } else {
         throw Error(`invalid synctable state -- unknown type '${type}'`);
       }
@@ -1183,7 +1190,9 @@ export class JupyterActions extends JupyterActions0 {
     const dbg = this.dbg("process_comm_message_from_kernel");
     // serializing the full message could cause enormous load on the server, since
     // the mesg may contain large buffers.  Only do for low level debugging!
-    // dbg(mesg); // EXTREME DANGER!
+    // EXTREME DANGER!
+    const TYPESCRIPT_ERROR_ON_PURPOSE_TO_REMIND_ME = false;
+    dbg(JSON.stringify(mesg));
     // This should be safe:
     dbg(JSON.stringify(mesg.header));
     if (this.syncdb.ipywidgets_state == null) {
diff --git a/src/packages/frontend/jupyter/widgets/manager.ts b/src/packages/frontend/jupyter/widgets/manager.ts
index d2ca3392dd..6c1762e320 100644
--- a/src/packages/frontend/jupyter/widgets/manager.ts
+++ b/src/packages/frontend/jupyter/widgets/manager.ts
@@ -69,6 +69,7 @@ export class WidgetManager extends base.ManagerBase<HTMLElement> {
     }
     this.setWidgetModelIdState = setWidgetModelIdState;
     this.init_ipywidgets_state();
+    window.x = this;
   }
 
   private async init_ipywidgets_state(): Promise<void> {
@@ -126,6 +127,9 @@ export class WidgetManager extends base.ManagerBase<HTMLElement> {
         case "message":
           this.handle_table_model_message_change(model_id);
           break;
+        case "message_to_kernel":
+          // only kernel handles this.
+          break;
         default:
           throw Error(`unknown state type '${type}'`);
       }
@@ -262,11 +266,34 @@ export class WidgetManager extends base.ManagerBase<HTMLElement> {
     model_id: string
   ): Promise<void> {
     const message = this.ipywidgets_state.get_message(model_id);
+    console.log("message = ", message, size(message));
     if (size(message) == 0) return; // temporary until we have delete functionality
     // console.log("handle_table_model_message_change", message);
-    const model = await this.get_model(model_id);
-    if (model == null) return;
-    model.trigger("msg:custom", message);
+    let d = 50;
+    for (let i = 0; i < 100; i++) {
+      const model = await this.get_model(model_id);
+      if (model != null) {
+        model.trigger("msg:custom", message);
+        return;
+      }
+      await delay(d);
+      d *= 1.2;
+    }
+    console.log(
+      "handle_table_model_message_change: unable to deliver message since model doesn't exist.",
+      { message, model_id }
+    );
+  }
+
+  private async handle_custom_message_from_model_to_kernel(
+    model_id: string,
+    message: object
+  ): Promise<void> {
+    console.log("handle_custom_message_from_model_to_kernel", {
+      model_id,
+      message,
+    });
+    this.ipywidgets_state.sendMessageToKernel(model_id, message);
   }
 
   async deserialize_state(
@@ -467,6 +494,16 @@ export class WidgetManager extends base.ManagerBase<HTMLElement> {
 
     // Start listening to model changes.
     model.on("change", this.handle_model_change.bind(this));
+
+    model.on("msg:custom", (message) => {
+      this.handle_custom_message_from_model_to_kernel(model_id, message);
+    });
+
+    // DEBUG For low level debugging, we can listen to all events from the model:
+    //     model.on("all", (...evt) => {
+    //       console.log("received event from model", evt);
+    //     });
+
     this.setWidgetModelIdState(model_id, "");
     // console.log("create_new-model - FINISHED", { model_id, serialized_state });
   }
@@ -482,6 +519,7 @@ export class WidgetManager extends base.ManagerBase<HTMLElement> {
     _data?: any,
     _metadata?: any
   ): Promise<Comm> {
+    console.log("TODO: _create_comm");
     const comm = new Comm(
       target_name,
       model_id,
@@ -495,7 +533,7 @@ export class WidgetManager extends base.ManagerBase<HTMLElement> {
     model_id: string,
     data: any
   ): string {
-    // console.log("TODO: process_comm_message_from_browser", model_id, data);
+    console.log("TODO: process_comm_message_from_browser", model_id, data);
     if (data == null) {
       throw Error("data must not be null");
     }
@@ -586,8 +624,7 @@ export class WidgetManager extends base.ManagerBase<HTMLElement> {
       // NOTE: I completely rewrote the entire k3d widget interface...
       module = await import("./k3d");
     } else if (moduleName === "jupyter-matplotlib") {
-      //module = await import("jupyter-matplotlib");
-      throw Error(`custom widgets: ${moduleName} not installed`);
+      module = await import("jupyter-matplotlib");
     } else if (moduleName === "jupyter-threejs") {
       //module = await import("jupyter-threejs");
       throw Error(`custom widgets: ${moduleName} not installed`);
diff --git a/src/packages/frontend/package-lock.json b/src/packages/frontend/package-lock.json
index 48d4e96485..213f6ef45e 100644
--- a/src/packages/frontend/package-lock.json
+++ b/src/packages/frontend/package-lock.json
@@ -21,11 +21,11 @@
         "@ant-design/icons": "^4.7.0",
         "@cocalc/assets": "^1.7.1",
         "@cocalc/cdn": "^1.12.0",
-        "@cocalc/hub": "^1.75.0",
+        "@cocalc/hub": "^1.76.3",
         "@cocalc/local-storage-lru": "^2.1.1",
         "@cocalc/project": "^1.27.4",
         "@cocalc/sync": "^0.8.2",
-        "@cocalc/util": "^1.53.0",
+        "@cocalc/util": "^1.55.0",
         "@jupyter-widgets/base": "^4.1.0",
         "@jupyter-widgets/controls": "^3.1.0",
         "@jupyter-widgets/output": "^4.1.0",
@@ -74,6 +74,7 @@
         "js-cookie": "^2.2.1",
         "json-stable-stringify": "^1.0.1",
         "jsonic": "^1.0.1",
+        "jupyter-matplotlib": "^0.11.1",
         "k3d": "^2.14.1",
         "katex": "^0.15.0",
         "langs": "^2.0.0",
@@ -197,7 +198,7 @@
     },
     "../hub": {
       "name": "@cocalc/hub",
-      "version": "1.75.0",
+      "version": "1.76.3",
       "license": "SEE LICENSE.md",
       "workspaces": [
         "./",
@@ -216,10 +217,10 @@
         "@cocalc/backend": "^1.17.3",
         "@cocalc/cdn": "^1.12.0",
         "@cocalc/database": "^0.24.0",
-        "@cocalc/frontend": "^1.69.0",
-        "@cocalc/next": "^0.61.0",
-        "@cocalc/server": "^0.33.0",
-        "@cocalc/static": "^1.103.0",
+        "@cocalc/frontend": "^1.70.3",
+        "@cocalc/next": "^0.62.2",
+        "@cocalc/server": "^0.33.1",
+        "@cocalc/static": "^1.104.2",
         "@cocalc/util": "^1.53.0",
         "@passport-next/passport-google-oauth2": "^1.0.0",
         "@passport-next/passport-oauth2": "^2.1.1",
@@ -426,7 +427,7 @@
     },
     "../util": {
       "name": "@cocalc/util",
-      "version": "1.53.0",
+      "version": "1.55.0",
       "license": "SEE LICENSE.md",
       "workspaces": [
         "."
@@ -6803,6 +6804,21 @@
         "verror": "1.10.0"
       }
     },
+    "node_modules/jupyter-matplotlib": {
+      "version": "0.11.1",
+      "resolved": "https://registry.npmjs.org/jupyter-matplotlib/-/jupyter-matplotlib-0.11.1.tgz",
+      "integrity": "sha512-grurKdGmbIz8i15PlsXuRsoPJIaWO6mAqCJ/775IlVTWpDvY8vor5uzD23U8ODSRUOIkDJJk5eH5tGHbCvkawQ==",
+      "dependencies": {
+        "@jupyter-widgets/base": "^2 || ^3 || ^4.0.0",
+        "@types/node": "^14.14.35",
+        "lodash": "^4.17.21"
+      }
+    },
+    "node_modules/jupyter-matplotlib/node_modules/@types/node": {
+      "version": "14.18.21",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.21.tgz",
+      "integrity": "sha512-x5W9s+8P4XteaxT/jKF0PSb7XEvo5VmqEWgsMlyeY4ZlLK8I6aH6g5TPPyDlLAep+GYf4kefb7HFyc7PAO3m+Q=="
+    },
     "node_modules/k3d": {
       "version": "2.14.1",
       "resolved": "https://registry.npmjs.org/k3d/-/k3d-2.14.1.tgz",
@@ -13382,6 +13398,7 @@
         "js-cookie": "^2.2.1",
         "json-stable-stringify": "^1.0.1",
         "jsonic": "^1.0.1",
+        "jupyter-matplotlib": "*",
         "k3d": "^2.14.1",
         "katex": "^0.15.0",
         "langs": "^2.0.0",
@@ -13424,7 +13441,7 @@
         "react-redux": "^7.2.0",
         "react-sortable-hoc": "^2.0.0",
         "react-timeago": "^6.2.1",
-        "react-virtualized-auto-sizer": "1.0.6",
+        "react-virtualized-auto-sizer": "^1.0.6",
         "react-virtuoso": "^2.13.3",
         "redux": "^4.0.1",
         "requirejs": "^2.3.6",
@@ -14655,10 +14672,10 @@
             "@cocalc/backend": "^1.17.3",
             "@cocalc/cdn": "^1.12.0",
             "@cocalc/database": "^0.24.0",
-            "@cocalc/frontend": "^1.69.0",
-            "@cocalc/next": "^0.61.0",
-            "@cocalc/server": "^0.33.0",
-            "@cocalc/static": "^1.103.0",
+            "@cocalc/frontend": "^1.70.3",
+            "@cocalc/next": "^0.62.2",
+            "@cocalc/server": "^0.33.1",
+            "@cocalc/static": "^1.104.2",
             "@cocalc/util": "^1.53.0",
             "@passport-next/passport-google-oauth2": "^1.0.0",
             "@passport-next/passport-oauth2": "^2.1.1",
@@ -18490,6 +18507,23 @@
             "verror": "1.10.0"
           }
         },
+        "jupyter-matplotlib": {
+          "version": "0.11.1",
+          "resolved": "https://registry.npmjs.org/jupyter-matplotlib/-/jupyter-matplotlib-0.11.1.tgz",
+          "integrity": "sha512-grurKdGmbIz8i15PlsXuRsoPJIaWO6mAqCJ/775IlVTWpDvY8vor5uzD23U8ODSRUOIkDJJk5eH5tGHbCvkawQ==",
+          "requires": {
+            "@jupyter-widgets/base": "^2 || ^3 || ^4.0.0",
+            "@types/node": "^14.14.35",
+            "lodash": "^4.17.21"
+          },
+          "dependencies": {
+            "@types/node": {
+              "version": "14.18.21",
+              "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.21.tgz",
+              "integrity": "sha512-x5W9s+8P4XteaxT/jKF0PSb7XEvo5VmqEWgsMlyeY4ZlLK8I6aH6g5TPPyDlLAep+GYf4kefb7HFyc7PAO3m+Q=="
+            }
+          }
+        },
         "k3d": {
           "version": "2.14.1",
           "resolved": "https://registry.npmjs.org/k3d/-/k3d-2.14.1.tgz",
@@ -22564,10 +22598,10 @@
         "@cocalc/backend": "^1.17.3",
         "@cocalc/cdn": "^1.12.0",
         "@cocalc/database": "^0.24.0",
-        "@cocalc/frontend": "^1.69.0",
-        "@cocalc/next": "^0.61.0",
-        "@cocalc/server": "^0.33.0",
-        "@cocalc/static": "^1.103.0",
+        "@cocalc/frontend": "^1.70.3",
+        "@cocalc/next": "^0.62.2",
+        "@cocalc/server": "^0.33.1",
+        "@cocalc/static": "^1.104.2",
         "@cocalc/util": "^1.53.0",
         "@passport-next/passport-google-oauth2": "^1.0.0",
         "@passport-next/passport-oauth2": "^2.1.1",
@@ -26399,6 +26433,23 @@
         "verror": "1.10.0"
       }
     },
+    "jupyter-matplotlib": {
+      "version": "0.11.1",
+      "resolved": "https://registry.npmjs.org/jupyter-matplotlib/-/jupyter-matplotlib-0.11.1.tgz",
+      "integrity": "sha512-grurKdGmbIz8i15PlsXuRsoPJIaWO6mAqCJ/775IlVTWpDvY8vor5uzD23U8ODSRUOIkDJJk5eH5tGHbCvkawQ==",
+      "requires": {
+        "@jupyter-widgets/base": "^2 || ^3 || ^4.0.0",
+        "@types/node": "^14.14.35",
+        "lodash": "^4.17.21"
+      },
+      "dependencies": {
+        "@types/node": {
+          "version": "14.18.21",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.21.tgz",
+          "integrity": "sha512-x5W9s+8P4XteaxT/jKF0PSb7XEvo5VmqEWgsMlyeY4ZlLK8I6aH6g5TPPyDlLAep+GYf4kefb7HFyc7PAO3m+Q=="
+        }
+      }
+    },
     "k3d": {
       "version": "2.14.1",
       "resolved": "https://registry.npmjs.org/k3d/-/k3d-2.14.1.tgz",
diff --git a/src/packages/frontend/package.json b/src/packages/frontend/package.json
index 91a505b94d..0f851af27c 100644
--- a/src/packages/frontend/package.json
+++ b/src/packages/frontend/package.json
@@ -96,6 +96,7 @@
     "js-cookie": "^2.2.1",
     "json-stable-stringify": "^1.0.1",
     "jsonic": "^1.0.1",
+    "jupyter-matplotlib": "^0.11.1",
     "k3d": "^2.14.1",
     "katex": "^0.15.0",
     "langs": "^2.0.0",
@@ -207,4 +208,4 @@
   "bugs": {
     "url": "https://github.com/sagemathinc/cocalc/issues"
   }
-}
\ No newline at end of file
+}
diff --git a/src/packages/project/client.coffee b/src/packages/project/client.coffee
index 7eef55bcd3..19589ac1b6 100644
--- a/src/packages/project/client.coffee
+++ b/src/packages/project/client.coffee
@@ -102,7 +102,7 @@ class exports.Client extends EventEmitter
             kucalc.init(@)
 
     # use to define a logging function that is cleanly used internally
-    dbg: (f, trunc=1000) =>
+    dbg: (f, trunc=10000) =>
         if DEBUG and @_winston
             return (m...) =>
                 switch m.length
diff --git a/src/packages/project/package.json b/src/packages/project/package.json
index b6b85fd8f9..94cc6b707c 100644
--- a/src/packages/project/package.json
+++ b/src/packages/project/package.json
@@ -91,7 +91,8 @@
   },
   "scripts": {
     "start": "NODE_OPTIONS='--trace-warnings --unhandled-rejections=strict --enable-source-maps' npx cocalc-project",
-    "build": "npx tsc && npx coffee -m -c -o dist/ .",
+    "build": "npx tsc && npm run coffee",
+    "coffee": "npx coffee -m -c -o dist/ .",
     "tsc": "npx tsc  --watch  --pretty --preserveWatchOutput"
   },
   "author": "SageMath, Inc.",
diff --git a/src/packages/static/webpack.config.js b/src/packages/static/webpack.config.js
index 26dbd8ce94..d5e6472ca8 100644
--- a/src/packages/static/webpack.config.js
+++ b/src/packages/static/webpack.config.js
@@ -159,6 +159,12 @@ module.exports = {
   module: {
     rules: require("./src/module-rules")(PRODMODE),
   },
+  ignoreWarnings: [
+    {
+      // The .map files are missing from jupyter-matplotlib, which causes several warnings.
+      module: /.*jupyter-matplotlib\/.*/,
+    },
+  ],
   resolve: {
     alias: {
       // @cocalc/frontend  alias so we can write `require("@cocalc/frontend/...")`
diff --git a/src/packages/sync/editor/generic/ipywidgets-state.ts b/src/packages/sync/editor/generic/ipywidgets-state.ts
index 1af302826c..ea600a4a09 100644
--- a/src/packages/sync/editor/generic/ipywidgets-state.ts
+++ b/src/packages/sync/editor/generic/ipywidgets-state.ts
@@ -28,6 +28,13 @@ type State = "init" | "ready" | "closed";
 
 type Value = { [key: string]: any };
 
+type TableType =
+  | "value"
+  | "state"
+  | "buffers"
+  | "message" // message to browser
+  | "message_to_kernel";
+
 // When there is no activity for this much time, them we
 // do some garbage collection.  This is only done in the
 // backend project, and not by frontend browser clients.
@@ -326,7 +333,7 @@ export class IpywidgetsState extends EventEmitter {
   // Do any setting of the underlying table through this function.
   public set(
     model_id: string,
-    type: "value" | "state" | "buffers" | "message",
+    type: TableType,
     data: any,
     fire_change_event: boolean = true
   ): void {
@@ -350,7 +357,7 @@ export class IpywidgetsState extends EventEmitter {
       // already set, but overwrite
       // when they change.
       merge = "deep";
-    } else if (type == "message") {
+    } else if (type == "message" || type == 'message_to_kernel') {
       merge = "none";
     } else {
       merge = "deep";
@@ -770,6 +777,14 @@ export class IpywidgetsState extends EventEmitter {
     this.set(model_id, "message", {}, fire_change_event);
   }
 
+  public sendMessageToKernel(model_id: string, message: object) {
+    this.set(model_id, "message_to_kernel", message, true);
+  }
+
+  public getLastMessageToKernel(model_id): object {
+    return this.get(model_id, "message_to_kernel")?.toJS();
+  }
+
   public get_message(model_id: string) {
     return this.get(model_id, "message")?.toJS();
   }