diff --git a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx
index cf1e121f93..8bb2fe767e 100644
--- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx
+++ b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx
@@ -9,6 +9,7 @@ import {
   waitFor,
 } from "@testing-library/react";
 import type { Host } from "@bb/domain";
+import { MemoryRouter } from "react-router-dom";
 import { afterEach, describe, expect, it, vi } from "vitest";
 import { BbHttpError, sdk } from "@/lib/sdk";
 import { hostsQueryKey } from "@/hooks/queries/query-keys";
@@ -76,11 +77,13 @@ describe("AddMachineDialog", () => {
 
     const { queryClient, wrapper } = createQueryClientTestHarness();
     render(
-      <AddMachineDialog
-        open
-        onOpenChange={vi.fn()}
-        serverUrl="http://direct.example.test:38886"
-      />,
+      <MemoryRouter>
+        <AddMachineDialog
+          open
+          onOpenChange={vi.fn()}
+          serverUrl="http://direct.example.test:38886"
+        />
+      </MemoryRouter>,
       { wrapper },
     );
 
@@ -155,11 +158,13 @@ describe("AddMachineDialog", () => {
 
     const { queryClient, wrapper } = createQueryClientTestHarness();
     render(
-      <AddMachineDialog
-        open
-        onOpenChange={vi.fn()}
-        serverUrl="http://direct.example.test:38886"
-      />,
+      <MemoryRouter>
+        <AddMachineDialog
+          open
+          onOpenChange={vi.fn()}
+          serverUrl="http://direct.example.test:38886"
+        />
+      </MemoryRouter>,
       { wrapper },
     );
 
@@ -191,4 +196,89 @@ describe("AddMachineDialog", () => {
     ).toBeDefined();
     expect(screen.queryByText("dev-vm connected")).toBeNull();
   });
+
+  it("explains that a loopback server is unreachable when connect is unpaired", async () => {
+    vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({
+      joinCode: "jc_test123",
+      hostId: "host_new",
+      expiresAt: Date.now() + 15 * 60 * 1000,
+    });
+    vi.mocked(sdk.plugins.callRpc).mockRejectedValue(
+      new BbHttpError({
+        body: {
+          ok: false,
+          error: { code: "handler_error", message: "not_paired" },
+        },
+        code: "handler_error",
+        message: "not_paired",
+        status: 500,
+      }),
+    );
+    vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]);
+
+    const { wrapper } = createQueryClientTestHarness();
+    render(
+      <MemoryRouter>
+        <AddMachineDialog
+          open
+          onOpenChange={vi.fn()}
+          serverUrl="http://127.0.0.1:38886"
+        />
+      </MemoryRouter>,
+      { wrapper },
+    );
+
+    // The desktop server listens on loopback only. Another machine cannot
+    // reach it, so a curl command against 127.0.0.1 can never work.
+    const notice = await screen.findByRole("status");
+    expect(notice.textContent).toContain(
+      "Another machine cannot use this address.",
+    );
+    expect(notice.textContent).toContain("http://127.0.0.1:38886");
+    expect(screen.queryByText(/--join-code jc_test123/)).toBeNull();
+    const link = screen.getByRole("link", { name: "Set up remote access" });
+    expect(link.getAttribute("href")).toBe("/settings/plugins/connect");
+    expect(
+      screen.queryByText("Waiting for the machine to connect…"),
+    ).toBeNull();
+  });
+
+  it("offers a retry when connect is temporarily unavailable on a loopback server", async () => {
+    vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({
+      joinCode: "jc_test123",
+      hostId: "host_new",
+      expiresAt: Date.now() + 15 * 60 * 1000,
+    });
+    vi.mocked(sdk.plugins.callRpc).mockRejectedValue(
+      new BbHttpError({
+        body: { error: "plugin starting" },
+        code: "unavailable",
+        message: "unavailable",
+        status: 503,
+      }),
+    );
+    vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]);
+
+    const { wrapper } = createQueryClientTestHarness();
+    render(
+      <MemoryRouter>
+        <AddMachineDialog
+          open
+          onOpenChange={vi.fn()}
+          serverUrl="http://0.0.0.0:38886"
+        />
+      </MemoryRouter>,
+      { wrapper },
+    );
+
+    // A 503 says nothing about pairing. Do not print a command that dials the
+    // new machine itself, and do not claim connect is unpaired: let the user
+    // retry.
+    expect(
+      await screen.findByText("Remote access isn't ready yet."),
+    ).toBeDefined();
+    expect(screen.getByRole("button", { name: "Try again" })).toBeDefined();
+    expect(screen.queryByText(/--join-code jc_test123/)).toBeNull();
+    expect(screen.queryByRole("status")).toBeNull();
+  });
 });
diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx
index 2aaed3a8db..eec88ad26e 100644
--- a/apps/app/src/components/dialogs/AddMachineDialog.tsx
+++ b/apps/app/src/components/dialogs/AddMachineDialog.tsx
@@ -1,4 +1,5 @@
 import { useEffect, useRef, useState } from "react";
+import { Link } from "react-router-dom";
 import { useMutation } from "@tanstack/react-query";
 import type { Host } from "@bb/domain";
 import { z } from "zod";
@@ -15,6 +16,8 @@ import { Icon } from "@bb/shared-ui/icon";
 import { MachineStatusDot } from "@/components/machines/MachineStatusDot";
 import { useHosts } from "@/hooks/queries/host-queries";
 import { useClipboardCopy } from "@/lib/clipboard";
+import { isLocalOnlyUrl } from "@/lib/loopback-hostname";
+import { getPluginConfigurationRoutePath } from "@/lib/route-paths";
 import { BbHttpError, sdk } from "@/lib/sdk";
 import { getMutationErrorMessage } from "@/lib/mutation-errors";
 
@@ -41,24 +44,39 @@ function isNotPairedRpcError(error: BbHttpError): boolean {
   return envelope.success && envelope.data.error.message === "not_paired";
 }
 
-async function createConnectMachineCode(): Promise<ConnectMachineCode | null> {
+/**
+ * Outcome of asking the connect plugin for a machine code.
+ * - `issued`: connect is paired; the command routes through getbb.app.
+ * - `unpaired`: connect is installed but not paired (or not installed at all).
+ *   Only a direct server URL can work.
+ * - `unavailable`: a temporary failure (for example the plugin is still
+ *   starting). Nothing is known about pairing.
+ */
+type ConnectMachineCodeResult =
+  | { kind: "issued"; code: ConnectMachineCode }
+  | { kind: "unpaired" }
+  | { kind: "unavailable" };
+
+async function createConnectMachineCode(): Promise<ConnectMachineCodeResult> {
   try {
-    return await sdk.plugins.callRpc({
+    const code = await sdk.plugins.callRpc({
       pluginId: "connect",
       method: "createMachineCode",
       input: null,
       outputSchema: connectMachineCodeSchema,
     });
+    return { kind: "issued", code };
   } catch (error) {
+    if (!(error instanceof BbHttpError)) throw error;
     if (
-      error instanceof BbHttpError &&
-      (error.code === "not_paired" ||
-        isNotPairedRpcError(error) ||
-        error.status === 404 ||
-        error.status === 422 ||
-        error.status === 503)
+      error.code === "not_paired" ||
+      isNotPairedRpcError(error) ||
+      error.status === 404
     ) {
-      return null;
+      return { kind: "unpaired" };
+    }
+    if (error.status === 422 || error.status === 503) {
+      return { kind: "unavailable" };
     }
     throw error;
   }
@@ -120,6 +138,54 @@ function pairingCommand(
   return `curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 ${serverUrl}/install.sh | sh -s -- --join-code ${joinCode} --host-id ${hostId} --server ${serverUrl}${machineFlag}`;
 }
 
+const REMOTE_ACCESS_ROUTE = getPluginConfigurationRoutePath({
+  pluginId: "connect",
+});
+
+/**
+ * Shown instead of the pairing command when connect is unpaired and the only
+ * server URL we know is loopback or unspecified (issue #1690). bb listens on
+ * loopback by default, so a command that targets this address dials the new
+ * machine itself instead of this server.
+ */
+function UnreachableServerNotice({ serverUrl }: { serverUrl: string }) {
+  return (
+    <div
+      role="status"
+      aria-live="polite"
+      className="space-y-2 rounded-md border border-border bg-muted/40 p-3"
+    >
+      <p className="text-sm text-foreground">
+        Another machine cannot use this address.
+      </p>
+      <p className="text-xs text-subtle-foreground">
+        The pairing command would target{" "}
+        <span className="font-mono">{serverUrl}</span>, which points to the
+        machine that runs it, not to this bb. Set up remote access first, then
+        come back here to get a pairing command that works from anywhere.
+      </p>
+      <div className="flex items-center gap-2">
+        <Button
+          asChild
+          size="sm"
+          variant="outline"
+          className="h-7 px-2.5 text-xs"
+        >
+          <Link to={REMOTE_ACCESS_ROUTE}>Set up remote access</Link>
+        </Button>
+        <a
+          href="https://github.com/get-bb/bb/blob/main/docs/multiple-devices.md"
+          target="_blank"
+          rel="noreferrer"
+          className="text-xs text-subtle-foreground underline underline-offset-2"
+        >
+          Other options
+        </a>
+      </div>
+    </div>
+  );
+}
+
 function AddMachineDialogContent({
   onOpenChange,
   serverUrl,
@@ -158,22 +224,38 @@ function AddMachineDialogContent({
         )
       : undefined) ?? null;
 
-  const [now, setNow] = useState(() => Date.now());
-  useEffect(() => {
-    const interval = window.setInterval(() => setNow(Date.now()), 1000);
-    return () => window.clearInterval(interval);
-  }, []);
-
   const joinCode = mintJoinCode.data?.join ?? null;
-  const machineCode = mintJoinCode.data?.machine ?? null;
+  const machineCodeResult = mintJoinCode.data?.machine ?? null;
+  const machineCode =
+    machineCodeResult?.kind === "issued" ? machineCodeResult.code : null;
   const expiresAt =
     joinCode === null
       ? null
       : Math.min(joinCode.expiresAt, machineCode?.expiresAt ?? Infinity);
-  const remainingMs = expiresAt !== null ? expiresAt - now : null;
+  const localOnlyServerUrl =
+    serverUrl !== null && isLocalOnlyUrl(serverUrl) ? serverUrl : null;
+  const unreachableServerUrl =
+    machineCodeResult?.kind === "unpaired" ? localOnlyServerUrl : null;
+  // Connect failed for a temporary reason and the fallback URL cannot work:
+  // offer a retry instead of a command that dials the wrong machine.
+  const connectUnavailable =
+    machineCodeResult?.kind === "unavailable" && localOnlyServerUrl !== null;
+  const showCommand =
+    joinCode !== null && unreachableServerUrl === null && !connectUnavailable;
+
+  // Tick only while a command with an expiry is on screen.
+  const [now, setNow] = useState(() => Date.now());
+  const hasCountdown = showCommand && expiresAt !== null;
+  useEffect(() => {
+    if (!hasCountdown) return;
+    const interval = window.setInterval(() => setNow(Date.now()), 1000);
+    return () => window.clearInterval(interval);
+  }, [hasCountdown]);
+  const remainingMs =
+    hasCountdown && expiresAt !== null ? expiresAt - now : null;
   const expired = remainingMs !== null && remainingMs <= 0;
   const command =
-    joinCode !== null
+    showCommand && joinCode !== null
       ? pairingCommand(
           joinCode.joinCode,
           joinCode.hostId,
@@ -188,18 +270,21 @@ function AddMachineDialogContent({
       <DialogHeader>
         <DialogTitle>Add a machine</DialogTitle>
         <DialogDescription>
-          Run this on the machine you want to add. It pairs the machine to this
-          server and keeps it available for your projects.
+          {unreachableServerUrl !== null
+            ? "Pair a machine to run projects and threads on it."
+            : "Run this on the machine you want to add. It pairs the machine to this server and keeps it available for your projects."}
         </DialogDescription>
       </DialogHeader>
       <div className="space-y-4">
-        {mintJoinCode.isError ? (
+        {mintJoinCode.isError || connectUnavailable ? (
           <div className="space-y-2">
             <p className="text-sm text-destructive">
-              {getMutationErrorMessage({
-                error: mintJoinCode.error,
-                fallbackMessage: "Couldn't create a join code.",
-              })}
+              {connectUnavailable
+                ? "Remote access isn't ready yet."
+                : getMutationErrorMessage({
+                    error: mintJoinCode.error,
+                    fallbackMessage: "Couldn't create a join code.",
+                  })}
             </p>
             <Button
               type="button"
@@ -210,6 +295,8 @@ function AddMachineDialogContent({
               Try again
             </Button>
           </div>
+        ) : unreachableServerUrl !== null ? (
+          <UnreachableServerNotice serverUrl={unreachableServerUrl} />
         ) : command !== null ? (
           <div className="space-y-2">
             <pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-md border border-border bg-muted/40 p-3 font-mono text-xs text-foreground">
@@ -259,35 +346,37 @@ function AddMachineDialogContent({
             Creating a join code…
           </p>
         )}
-        <div className="flex items-center gap-2.5 rounded-md border border-border bg-muted/40 px-3 py-2.5">
-          {connectedNewHost !== null ? (
-            <>
-              <MachineStatusDot connected />
-              <span className="min-w-0 flex-1 truncate text-sm text-foreground">
-                {connectedNewHost.name} connected
-              </span>
-              <Button
-                type="button"
-                size="sm"
-                variant="ghost"
-                className="h-7 shrink-0 px-2 text-xs"
-                onClick={() => onOpenChange(false)}
-              >
-                Set up a project on it →
-              </Button>
-            </>
-          ) : (
-            <>
-              <Icon
-                name="Spinner"
-                className="size-4 shrink-0 animate-spin text-muted-foreground"
-              />
-              <span className="text-sm text-muted-foreground">
-                Waiting for the machine to connect…
-              </span>
-            </>
-          )}
-        </div>
+        {unreachableServerUrl !== null ? null : (
+          <div className="flex items-center gap-2.5 rounded-md border border-border bg-muted/40 px-3 py-2.5">
+            {connectedNewHost !== null ? (
+              <>
+                <MachineStatusDot connected />
+                <span className="min-w-0 flex-1 truncate text-sm text-foreground">
+                  {connectedNewHost.name} connected
+                </span>
+                <Button
+                  type="button"
+                  size="sm"
+                  variant="ghost"
+                  className="h-7 shrink-0 px-2 text-xs"
+                  onClick={() => onOpenChange(false)}
+                >
+                  Set up a project on it →
+                </Button>
+              </>
+            ) : (
+              <>
+                <Icon
+                  name="Spinner"
+                  className="size-4 shrink-0 animate-spin text-muted-foreground"
+                />
+                <span className="text-sm text-muted-foreground">
+                  Waiting for the machine to connect…
+                </span>
+              </>
+            )}
+          </div>
+        )}
       </div>
       <DialogFooter>
         <Button
diff --git a/apps/app/src/components/secondary-panel/BrowserTabContent.tsx b/apps/app/src/components/secondary-panel/BrowserTabContent.tsx
index 76cfe4beac..83ac6a0b1f 100644
--- a/apps/app/src/components/secondary-panel/BrowserTabContent.tsx
+++ b/apps/app/src/components/secondary-panel/BrowserTabContent.tsx
@@ -45,7 +45,7 @@ import {
 } from "@/components/commands/AppCommandProvider";
 import type { AppShortcutPresentation } from "@/lib/app-keybindings";
 import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@/components/ui/chromeStyleTokens";
-import { isLoopbackHostname } from "@/lib/loopback-hostname";
+import { isLocalOnlyUrl } from "@/lib/loopback-hostname";
 
 export interface BrowserTabContentProps {
   tabId: string;
@@ -180,19 +180,11 @@ function browserViewBoundsEqual(args: BrowserViewBoundsEqualArgs): boolean {
   );
 }
 
-function isLocalBrowserUrl(url: string): boolean {
-  try {
-    return isLoopbackHostname(new URL(url).hostname);
-  } catch {
-    return false;
-  }
-}
-
 function browserPageLoadErrorTitle(args: {
   errorText: string;
   url: string;
 }): string {
-  if (isLocalBrowserUrl(args.url)) {
+  if (isLocalOnlyUrl(args.url)) {
     return "Server not reachable";
   }
   if (args.errorText.includes("ERR_BLOCKED_BY_CLIENT")) {
@@ -382,7 +374,7 @@ function BrowserPageLoadError({
 }: BrowserPageLoadErrorProps) {
   const host = getBrowserUrlHost(url);
   const title = browserPageLoadErrorTitle({ errorText, url });
-  const message = isLocalBrowserUrl(url)
+  const message = isLocalOnlyUrl(url)
     ? `The browser could not reach ${host || "this local server"}. Start the server, then reload.`
     : "The browser could not load this page. Try reloading or opening it externally.";
 
diff --git a/apps/app/src/lib/loopback-hostname.test.ts b/apps/app/src/lib/loopback-hostname.test.ts
index adcce6943c..a33e68392b 100644
--- a/apps/app/src/lib/loopback-hostname.test.ts
+++ b/apps/app/src/lib/loopback-hostname.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { isLoopbackHostname } from "./loopback-hostname";
+import { isLocalOnlyUrl, isLoopbackHostname } from "./loopback-hostname";
 
 describe("isLoopbackHostname", () => {
   it.each([
@@ -26,3 +26,25 @@ describe("isLoopbackHostname", () => {
     expect(isLoopbackHostname(hostname)).toBe(false);
   });
 });
+
+describe("isLocalOnlyUrl", () => {
+  it.each([
+    "http://127.0.0.1:38886",
+    "http://localhost:38886",
+    "http://[::1]:38886",
+    "http://0.0.0.0:38886",
+    "http://[::]:38886",
+    "http://[::ffff:127.0.0.1]:38886",
+  ])("recognizes %s as local-only", (url) => {
+    expect(isLocalOnlyUrl(url)).toBe(true);
+  });
+
+  it.each([
+    "https://mac.tailnet.ts.net",
+    "http://192.168.1.10:38886",
+    "http://[::ffff:192.168.1.10]:38886",
+    "not a url",
+  ])("does not treat %s as local-only", (url) => {
+    expect(isLocalOnlyUrl(url)).toBe(false);
+  });
+});
diff --git a/apps/app/src/lib/loopback-hostname.ts b/apps/app/src/lib/loopback-hostname.ts
index 8b0532f52e..209299f06f 100644
--- a/apps/app/src/lib/loopback-hostname.ts
+++ b/apps/app/src/lib/loopback-hostname.ts
@@ -32,3 +32,35 @@ export function isLoopbackHostname(hostname: string): boolean {
     isIpv4LoopbackHostname(normalizedHostname)
   );
 }
+
+/**
+ * Whether a hostname is loopback or unspecified (`0.0.0.0`, `::`,
+ * `::ffff:127.x.x.x`). Such an address never routes to a remote machine: a
+ * client that dials it reaches itself.
+ */
+export function isLocalOnlyHostname(hostname: string): boolean {
+  const normalizedHostname = normalizeHostname(hostname);
+  if (isLoopbackHostname(normalizedHostname)) return true;
+  if (normalizedHostname === "0.0.0.0" || normalizedHostname === "::") {
+    return true;
+  }
+  // IPv4-mapped IPv6, dotted (`::ffff:127.0.0.1`) or hex (`::ffff:7f00:1`,
+  // the form the URL parser emits).
+  const dotted = normalizedHostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/u);
+  if (dotted !== null) return isLoopbackHostname(dotted[1] ?? "");
+  const hex = normalizedHostname.match(
+    /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u,
+  );
+  if (hex === null) return false;
+  const high = Number.parseInt(hex[1] ?? "", 16);
+  return high >> 8 === 127;
+}
+
+/** Whether a URL's hostname is loopback or unspecified. Invalid URLs are not. */
+export function isLocalOnlyUrl(url: string): boolean {
+  try {
+    return isLocalOnlyHostname(new URL(url).hostname);
+  } catch {
+    return false;
+  }
+}
diff --git a/docs/multiple-devices.md b/docs/multiple-devices.md
index 8b7c36da58..b4a42524fa 100644
--- a/docs/multiple-devices.md
+++ b/docs/multiple-devices.md
@@ -100,7 +100,9 @@ execute work. It installs and enrolls a host daemon; when bb connect is paired,
 the installer also configures the machine credential used to reach the server
 through the account gate. Without bb connect, open the server through a
 Tailscale Serve URL before generating the installer; the loopback listener is
-not directly reachable from another machine.
+not directly reachable from another machine. When bb connect is not paired and
+the server URL is a loopback or unspecified address, the dialog does not show an
+installer. It links to Settings → Remote access instead.
 
 The installer always installs the exact `bb-app` package exposed by that
 server at `/install/bb-app.tgz`; a `bb-app` already on PATH is reused, and the
