diff --git a/scripts/hab-build-and-push.sh b/scripts/hab-build-and-push.sh
index f9ffb81ad1ef8e780bdc4efd8499eee9c0eab4c4..1f0b129f00851cfae504fc347b17adfdde7ffb3f 100755
--- a/scripts/hab-build-and-push.sh
+++ b/scripts/hab-build-and-push.sh
@@ -31,6 +31,7 @@ npm rebuild node-sass # HACK sometimes node-sass build fails
 npm run build
 mkdir dist/pages
 mv dist/*.html dist/pages
+mv dist/hub.service.js dist/pages
 
 aws s3 sync --acl public-read --cache-control "max-age=31556926" dist/assets "$TARGET_S3_URL/assets"
 aws s3 sync --acl public-read --cache-control "no-cache" --delete dist/pages "$TARGET_S3_URL/pages/latest"
diff --git a/src/assets/stylesheets/entry.scss b/src/assets/stylesheets/entry.scss
index f0424741082257a992e0adfd24de80703d1899c7..d94b199b07ffcaf6b6af11904fb8a779804ea13a 100644
--- a/src/assets/stylesheets/entry.scss
+++ b/src/assets/stylesheets/entry.scss
@@ -184,4 +184,24 @@
     cursor: pointer;
     color: $dark-grey-text;
   }
+
+  :local(.subscribe) {
+    margin-bottom: 24px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: $darker-grey;
+
+    input {
+      @extend %checkbox;
+    }
+
+    input:checked {
+      @extend %checkbox-checked;
+    }
+
+    label {
+      margin-right: 8px;
+    }
+  }
 }
diff --git a/src/assets/stylesheets/hub-create.scss b/src/assets/stylesheets/hub-create.scss
index bc9145df6e1246e5a34e60f29efc7fffe7297414..2af9cb72e0c16e35f58b5df5ffd16faadd90dca0 100644
--- a/src/assets/stylesheets/hub-create.scss
+++ b/src/assets/stylesheets/hub-create.scss
@@ -51,12 +51,12 @@
       border-radius: 0px 0px 14px 14px;
 
       &::selection {
-        background-color: #2F80ED;
+        background-color: $bright-blue;
         color: white;
       }
 
       &::-moz-selection {
-        background-color: #2F80ED;
+        background-color: $bright-blue;
         color: white;
       }
 
diff --git a/src/assets/stylesheets/index.scss b/src/assets/stylesheets/index.scss
index 66595775efe1e43c7a835987940ff288eee63ef1..46029a3b32be5675fd1448ceb65ca9375e6ddd66 100644
--- a/src/assets/stylesheets/index.scss
+++ b/src/assets/stylesheets/index.scss
@@ -172,7 +172,7 @@ body {
 
   :local(.create) {
     padding: 2.1em;
-    padding-bottom: 3.5vw;
+    padding-bottom: 2vw;
     position: relative;
 
     @media (max-width: 768px) {
@@ -189,6 +189,17 @@ body {
       @extend %action-button;
     }
   }
+
+  :local(.spoke-button) {
+    display: flex;
+    justify-content: center;
+
+    a {
+      margin-top: 16px;
+      @extend %action-button;
+      background: $spoke-action-color;
+    }
+  }
 }
 
 :local(.footer-content) {
diff --git a/src/assets/stylesheets/shared.scss b/src/assets/stylesheets/shared.scss
index 79a12fc6febb433dfa5453fa2729454971cbb690..d44b6b31916977101aea39ee8824cee35e388559 100644
--- a/src/assets/stylesheets/shared.scss
+++ b/src/assets/stylesheets/shared.scss
@@ -10,10 +10,12 @@ $light-grey: lightgrey;
 $dark-grey: rgba(128, 128, 128, 1.0);
 $darker-grey: rgba(64, 64, 64, 1.0);
 $darkest-grey: rgba(32, 32, 32, 1.0);
+$bright-blue: #2F80ED;
 $action-color: #FF3464;
 $action-color-light: #FF74A4;
 $action-color-transparent: rgba(255, 52, 100, 0.9);
 $hud-panel-background: rgba(79, 79, 79, 0.45);
+$spoke-action-color: $bright-blue;
 
 %unselectable {
   -moz-user-select: none;
@@ -153,16 +155,15 @@ $hud-panel-background: rgba(79, 79, 79, 0.45);
   -webkit-appearance: none;
   width: 2em;
   height: 2em;
-  border: 1px solid #e2e2e2;
-  border-radius: 9px;
+  border: 3px solid #aaa;
+  border-radius: 6px;
   vertical-align: sub;
   margin: 0 0.6em
 }
 
 %checkbox-checked {
-  border: 9px double #aaa;
-  outline: 9px solid #aaa;
-  outline-offset: -18px;
+  outline: 9px solid $action-color;
+  outline-offset: -15px;
 }
 
 %background-agnostic {
diff --git a/src/assets/stylesheets/spoke.scss b/src/assets/stylesheets/spoke.scss
index 68f850879027e250f7bcce75a16a88692cf4a6e3..0219502e7a00014e7cbb07530ca3a78e06353b73 100644
--- a/src/assets/stylesheets/spoke.scss
+++ b/src/assets/stylesheets/spoke.scss
@@ -1,7 +1,6 @@
 @import 'shared';
 @import 'loader';
 
-$spoke-action-color: #2F80ED;
 $breakpoint: 1280px;
 $mobile-breakpoint-width: 450px;
 
diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 47fef3295a0b67e0216c506bb504b8e96c9bf2d0..819a5a04649cf73d1cc12f9d8b64f6cd1b5ababd 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -32,6 +32,7 @@
     "entry.enable-screen-sharing": "Share my desktop",
     "entry.return-to-vr": "Enter in VR",
     "entry.lobby": "Lobby",
+    "entry.notify_me": "Notify me when others are here",
     "profile.save": "Accept",
     "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32",
     "profile.header": "Name & Avatar",
@@ -68,6 +69,7 @@
     "home.create_name.validation_warning": "Invalid name, limited to 4 to 64 characters and limited symbols.",
     "home.join_us": "Join the Conversation",
     "home.join_room": "Join Room",
+    "home.create_with_spoke": "Create a Scene",
     "home.report_issue": "Report Issues",
     "home.source_link": "Source",
     "home.spoke_link": "Spoke",
diff --git a/src/hub.js b/src/hub.js
index ef6e6eb9d9326b8cad54f431cc80816f85a1be24..f16044fa877f9afc61ee9c4b4b5a7aedd321a5fa 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -70,6 +70,7 @@ import { connectToReticulum } from "./utils/phoenix-utils";
 import { disableiOSZoom } from "./utils/disable-ios-zoom";
 import { proxiedUrlFor } from "./utils/media-utils";
 import SceneEntryManager from "./scene-entry-manager";
+import Subscriptions from "./subscriptions";
 
 import "./systems/nav";
 import "./systems/personal-space-bubble";
@@ -132,7 +133,6 @@ if (!isBotMode && !isTelemetryDisabled) {
 disableiOSZoom();
 
 const concurrentLoadDetector = new ConcurrentLoadDetector();
-
 concurrentLoadDetector.start();
 
 store.init();
@@ -286,6 +286,26 @@ async function runBotMode(scene, entryManager) {
 }
 
 document.addEventListener("DOMContentLoaded", async () => {
+  const hubId = qs.get("hub_id") || document.location.pathname.substring(1).split("/")[0];
+  console.log(`Hub ID: ${hubId}`);
+
+  const subscriptions = new Subscriptions(hubId);
+
+  if (navigator.serviceWorker) {
+    try {
+      navigator.serviceWorker
+        .register("/hub.service.js")
+        .then(() => {
+          navigator.serviceWorker.ready
+            .then(registration => subscriptions.setRegistration(registration))
+            .catch(() => subscriptions.setRegistrationFailed());
+        })
+        .catch(() => subscriptions.setRegistrationFailed());
+    } catch (e) {
+      subscriptions.setRegistrationFailed();
+    }
+  }
+
   const scene = document.querySelector("a-scene");
   const hubChannel = new HubChannel(store);
   const entryManager = new SceneEntryManager(hubChannel);
@@ -296,7 +316,16 @@ document.addEventListener("DOMContentLoaded", async () => {
   window.APP.scene = scene;
 
   registerNetworkSchemas();
-  remountUI({ hubChannel, linkChannel, enterScene: entryManager.enterScene, exitScene: entryManager.exitScene });
+
+  remountUI({
+    hubChannel,
+    linkChannel,
+    subscriptions,
+    enterScene: entryManager.enterScene,
+    exitScene: entryManager.exitScene,
+    initialIsSubscribed: subscriptions.isSubscribed()
+  });
+
   scene.addEventListener("action_focus_chat", () => document.querySelector(".chat-focus-target").focus());
 
   pollForSupportAvailability(isSupportAvailable => remountUI({ isSupportAvailable }));
@@ -345,10 +374,6 @@ document.addEventListener("DOMContentLoaded", async () => {
     }
   });
 
-  // Connect to reticulum over phoenix channels to get hub info.
-  const hubId = qs.get("hub_id") || document.location.pathname.substring(1).split("/")[0];
-  console.log(`Hub ID: ${hubId}`);
-
   const socket = connectToReticulum(isDebug);
   remountUI({ sessionId: socket.params().session_id });
 
@@ -358,13 +383,27 @@ document.addEventListener("DOMContentLoaded", async () => {
     hmd: availableVREntryTypes.isInHMD
   };
 
-  const joinPayload = { profile: store.state.profile, context };
+  // Reticulum global channel
+  const retPhxChannel = socket.channel(`ret`, { hub_id: hubId });
+  retPhxChannel
+    .join()
+    .receive("ok", async data => subscriptions.setVapidPublicKey(data.vapid_public_key))
+    .receive("error", res => {
+      subscriptions.setVapidPublicKey(null);
+      console.error(res);
+    });
+
+  const pushSubscriptionEndpoint = await subscriptions.getCurrentEndpoint();
+  const joinPayload = { profile: store.state.profile, push_subscription_endpoint: pushSubscriptionEndpoint, context };
   const hubPhxChannel = socket.channel(`hub:${hubId}`, joinPayload);
 
   hubPhxChannel
     .join()
     .receive("ok", async data => {
       hubChannel.setPhoenixChannel(hubPhxChannel);
+      subscriptions.setHubChannel(hubChannel);
+      subscriptions.setSubscribed(data.subscriptions.web_push);
+      remountUI({ initialIsSubscribed: subscriptions.isSubscribed() });
       await handleHubChannelJoined(entryManager, hubChannel, data);
     })
     .receive("error", res => {
@@ -470,8 +509,5 @@ document.addEventListener("DOMContentLoaded", async () => {
     addToPresenceLog({ name: userInfo.metas[0].profile.displayName, type, body, maySpawn });
   });
 
-  // Reticulum global channel
-  const retPhxChannel = socket.channel(`ret`, { hub_id: hubId });
-  retPhxChannel.join().receive("error", res => console.error(res));
   linkChannel.setSocket(socket);
 });
diff --git a/src/hub.service.js b/src/hub.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..73a51a649b07c95425d98c389b0b848a56070968
--- /dev/null
+++ b/src/hub.service.js
@@ -0,0 +1,46 @@
+self.addEventListener("install", function(e) {
+  return e.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener("activate", function(e) {
+  return e.waitUntil(self.clients.claim());
+});
+
+self.addEventListener("push", function(e) {
+  const payload = JSON.parse(e.data.text());
+
+  return e.waitUntil(
+    self.clients.matchAll({ type: "window" }).then(function(clientList) {
+      for (let i = 0; i < clientList.length; i++) {
+        const client = clientList[i];
+        if (client.url.indexOf(e.notification.data.hub_id) >= 0) return;
+      }
+
+      return self.registration.showNotification("Hubs by Mozilla", {
+        body: "Someone has joined " + payload.hub_name,
+        image: payload.image,
+        icon: "/favicon.ico",
+        badge: "/favicon.ico",
+        tag: payload.hub_id,
+        data: { hub_url: payload.hub_url }
+      });
+    })
+  );
+});
+
+self.addEventListener("notificationclick", function(e) {
+  e.notification.close();
+
+  e.waitUntil(
+    self.clients.matchAll({ type: "window" }).then(function(clientList) {
+      for (let i = 0; i < clientList.length; i++) {
+        const client = clientList[i];
+        if (client.url.indexOf(e.notification.data.hub_url) >= 0 && "focus" in client) return client.focus();
+      }
+
+      if (self.clients.openWindow) {
+        return self.clients.openWindow(e.notification.data.hub_url);
+      }
+    })
+  );
+});
diff --git a/src/react-components/home-root.js b/src/react-components/home-root.js
index 8dfc8bd5c652b162b8518651107770d0a41554ca..c95a342fc2d4ac12755a48321a3bcb97848aa4bb 100644
--- a/src/react-components/home-root.js
+++ b/src/react-components/home-root.js
@@ -218,10 +218,17 @@ class HomeRoot extends Component {
                 />
               </div>
               {this.state.environments.length > 1 && (
-                <div className={styles.joinButton}>
-                  <a href="/link">
-                    <FormattedMessage id="home.join_room" />
-                  </a>
+                <div>
+                  <div className={styles.joinButton}>
+                    <a href="/link">
+                      <FormattedMessage id="home.join_room" />
+                    </a>
+                  </div>
+                  <div className={styles.spokeButton}>
+                    <a href="/spoke">
+                      <FormattedMessage id="home.create_with_spoke" />
+                    </a>
+                  </div>
                 </div>
               )}
             </div>
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index c779d33fd89b4fe400e3520b0d93c488b0e47a63..e35b309721c1f21e30f7a366a5a2c683522cb618 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -89,7 +89,9 @@ class UIRoot extends Component {
     isSupportAvailable: PropTypes.bool,
     presenceLogEntries: PropTypes.array,
     presences: PropTypes.object,
-    sessionId: PropTypes.string
+    sessionId: PropTypes.string,
+    subscriptions: PropTypes.object,
+    initialIsSubscribed: PropTypes.bool
   };
 
   state = {
@@ -147,6 +149,11 @@ class UIRoot extends Component {
     this.props.scene.removeEventListener("exit", this.exit);
   }
 
+  updateSubscribedState = () => {
+    const isSubscribed = this.props.subscriptions && this.props.subscriptions.isSubscribed();
+    this.setState({ isSubscribed });
+  };
+
   onSceneLoaded = () => {
     this.setState({ sceneLoaded: true });
   };
@@ -175,6 +182,13 @@ class UIRoot extends Component {
     this.props.scene.emit("penButtonPressed");
   };
 
+  onSubscribeChanged = async () => {
+    if (!this.props.subscriptions) return;
+
+    await this.props.subscriptions.toggle();
+    this.updateSubscribedState();
+  };
+
   handleStartEntry = () => {
     const promptForNameAndAvatarBeforeEntry = !this.props.store.state.activity.hasChangedName;
 
@@ -673,9 +687,13 @@ class UIRoot extends Component {
   };
 
   renderEntryStartPanel = () => {
+<<<<<<< HEAD
     const textRows = this.state.pendingMessage.split("\n").length;
     const pendingMessageTextareaHeight = textRows * 28 + "px";
     const pendingMessageFieldHeight = textRows * 28 + 20 + "px";
+=======
+    const hasPush = navigator.serviceWorker && "PushManager" in window;
+>>>>>>> 58f4eba535de1894589cb9dfa455da3e3376fb8d
 
     return (
       <div className={entryStyles.entryPanel}>
@@ -710,6 +728,20 @@ class UIRoot extends Component {
           </form>
         </div>
 
+        {hasPush && (
+          <div className={entryStyles.subscribe}>
+            <input
+              id="subscribe"
+              type="checkbox"
+              onChange={this.onSubscribeChanged}
+              checked={this.state.isSubscribed === undefined ? this.props.initialIsSubscribed : this.state.isSubscribed}
+            />
+            <label htmlFor="subscribe">
+              <FormattedMessage id="entry.notify_me" />
+            </label>
+          </div>
+        )}
+
         <div className={entryStyles.buttonContainer}>
           <button
             className={classNames([entryStyles.actionButton, entryStyles.wideButton])}
diff --git a/src/subscriptions.js b/src/subscriptions.js
new file mode 100644
index 0000000000000000000000000000000000000000..a5d9fc8afab5ad7b81fdeaf54fc9ecd90bab17f2
--- /dev/null
+++ b/src/subscriptions.js
@@ -0,0 +1,101 @@
+import nextTick from "./utils/next-tick.js";
+
+// Manages web push subscriptions
+//
+function urlBase64ToUint8Array(base64String) {
+  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
+
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+
+  for (let i = 0; i < rawData.length; ++i) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+  return outputArray;
+}
+
+export default class Subscriptions {
+  constructor(hubId) {
+    this.hubId = hubId;
+  }
+
+  setHubChannel = hubChannel => {
+    this.hubChannel = hubChannel;
+  };
+
+  setRegistration = registration => {
+    this.registration = registration;
+  };
+
+  setRegistrationFailed = () => {
+    this.registration = null;
+  };
+
+  setVapidPublicKey = vapidPublicKey => {
+    this.vapidPublicKey = vapidPublicKey;
+  };
+
+  setSubscribed = isSubscribed => {
+    this._isSubscribed = isSubscribed;
+  };
+
+  isSubscribed = () => {
+    return this._isSubscribed;
+  };
+
+  getCurrentEndpoint = async () => {
+    if (!navigator.serviceWorker) return null;
+
+    // registration becomes null if failed, non null if registered
+    while (this.registration === undefined) await nextTick();
+    if (!this.registration || !this.registration.pushManager) return null;
+
+    while (this.vapidPublicKey === undefined) await nextTick();
+    if (this.vapidPublicKey === null) return null;
+
+    try {
+      const convertedVapidKey = urlBase64ToUint8Array(this.vapidPublicKey);
+
+      if (
+        (await this.registration.pushManager.permissionState({
+          userVisibleOnly: true,
+          applicationServerKey: convertedVapidKey
+        })) !== "granted"
+      )
+        return null;
+    } catch (e) {
+      return null; // Chrome can throw here complaining about userVisible if push is not right
+    }
+    const sub = await this.registration.pushManager.getSubscription();
+    if (!sub) return null;
+
+    return sub.endpoint;
+  };
+
+  toggle = async () => {
+    if (this._isSubscribed) {
+      const pushSubscription = await this.registration.pushManager.getSubscription();
+      const res = await this.hubChannel.unsubscribe(pushSubscription);
+
+      if (res && res.has_remaining_subscriptions === false) {
+        pushSubscription.unsubscribe();
+      }
+    } else {
+      let pushSubscription = await this.registration.pushManager.getSubscription();
+
+      if (!pushSubscription) {
+        const convertedVapidKey = urlBase64ToUint8Array(this.vapidPublicKey);
+
+        pushSubscription = await this.registration.pushManager.subscribe({
+          userVisibleOnly: true,
+          applicationServerKey: convertedVapidKey
+        });
+      }
+
+      this.hubChannel.subscribe(pushSubscription);
+    }
+
+    this._isSubscribed = !this._isSubscribed;
+  };
+}
diff --git a/src/systems/userinput/devices/mouse.js b/src/systems/userinput/devices/mouse.js
index 0ab7a3ac2aea0ce0ec44ad55fb5804348443bcae..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
--- a/src/systems/userinput/devices/mouse.js
+++ b/src/systems/userinput/devices/mouse.js
@@ -1,77 +0,0 @@
-import { paths } from "../paths";
-
-// TODO: Where do these values (500, 10, 2) come from?
-const modeMod = {
-  [WheelEvent.DOM_DELTA_PIXEL]: 500,
-  [WheelEvent.DOM_DELTA_LINE]: 10,
-  [WheelEvent.DOM_DELTA_PAGE]: 2
-};
-
-export class MouseDevice {
-  constructor() {
-    this.events = [];
-    this.coords = [0, 0]; // normalized screenspace coordinates in [(-1, 1), (-1, 1)]
-    this.movementXY = [0, 0]; // deltas
-    this.buttonLeft = false;
-    this.buttonRight = false;
-    this.wheel = 0; // delta
-
-    const queueEvent = this.events.push.bind(this.events);
-    const canvas = document.querySelector("canvas");
-    ["mousedown", "mouseup", "mousemove", "wheel"].map(x => canvas.addEventListener(x, queueEvent));
-    ["mouseout", "blur"].map(x => document.addEventListener(x, queueEvent));
-  }
-
-  process(event) {
-    if (event.type === "wheel") {
-      this.wheel += event.deltaY / modeMod[event.deltaMode];
-      return;
-    }
-    if (event.type === "mouseout" || event.type === "blur") {
-      this.coords[0] = 0;
-      this.coords[1] = 0;
-      this.movementXY[0] = 0;
-      this.movementXY[1] = 0;
-      this.buttonLeft = false;
-      this.buttonRight = false;
-      this.wheel = 0;
-    }
-    const left = event.button === 0;
-    const right = event.button === 2;
-    this.coords[0] = (event.clientX / window.innerWidth) * 2 - 1;
-    this.coords[1] = -(event.clientY / window.innerHeight) * 2 + 1;
-    this.movementXY[0] += event.movementX;
-    this.movementXY[1] += event.movementY;
-    if (event.type === "mousedown" && left) {
-      this.buttonLeft = true;
-    } else if (event.type === "mousedown" && right) {
-      this.buttonRight = true;
-    } else if (event.type === "mouseup" && left) {
-      this.buttonLeft = false;
-    } else if (event.type === "mouseup" && right) {
-      this.buttonRight = false;
-    }
-  }
-
-  write(frame) {
-    this.movementXY = [0, 0]; // deltas
-    this.wheel = 0; // delta
-    this.events.forEach(event => {
-      this.process(event, frame);
-    });
-
-    while (this.events.length) {
-      this.events.pop();
-    }
-
-    frame[paths.device.mouse.coords] = this.coords;
-    frame[paths.device.mouse.movementXY] = this.movementXY;
-    frame[paths.device.mouse.buttonLeft] = this.buttonLeft;
-    frame[paths.device.mouse.buttonRight] = this.buttonRight;
-    frame[paths.device.mouse.wheel] = this.wheel;
-  }
-}
-
-window.oncontextmenu = e => {
-  e.preventDefault();
-};
diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js
index 734cafa5fcc11c8ef84d708da4e16c32b54396aa..81714efc2fe069ce43cc8d0721c4550a2f42667d 100644
--- a/src/utils/hub-channel.js
+++ b/src/utils/hub-channel.js
@@ -91,6 +91,14 @@ export default class HubChannel {
     this.channel.push("events:profile_updated", { profile: this.store.state.profile });
   };
 
+  subscribe = subscription => {
+    this.channel.push("subscribe", { subscription });
+  };
+
+  unsubscribe = subscription => {
+    return new Promise(resolve => this.channel.push("unsubscribe", { subscription }).receive("ok", resolve));
+  };
+
   sendMessage = (body, type = "chat") => {
     if (!body) return;
     this.channel.push("message", { body, type });
diff --git a/webpack.config.js b/webpack.config.js
index e6567532eb15a79f243b7e8a5df1b9064e5eeefd..55afb6708d844a885ade6473327360ca6198c243 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -240,6 +240,12 @@ module.exports = (env, argv) => ({
         to: "hub-preview.png"
       }
     ]),
+    new CopyWebpackPlugin([
+      {
+        from: "src/hub.service.js",
+        to: "hub.service.js"
+      }
+    ]),
     // Extract required css and add a content hash.
     new ExtractTextPlugin({
       filename: "assets/stylesheets/[name]-[md5:contenthash:hex:20].css",