all: use make to build

This overhauls the build system.
We used to use a just-in-time clever build system
so that 'go run' and 'go install' Just Worked.

This was really nice, except that it make it
all but impossible to ship a single binary.
It also required our uses to install npm,
which some folks have an understandably negative reaction to.

This migrates to a makefile for building.
The core typescript building logic is mostly still in Go,
and untouched (boy did I learn that lesson the hard way).

The output is a single file that includes the webui, innie, and outie.

(There are still very mild shenanigans in which we write outie
out to a temp file and then 'docker cp' it into the docker container.
But this is pretty manageable.)

There are some significant follow-ups left after this commit:

- convert the nightly release builds to use the makefile
- lots of dead code removal
- maybe add -race support using a dockerfile for the cgo compilation
- maybe use 'docker cp' stdin reading with tar to avoid the temp outtie file
- all the rest of the "better release" todos (brew install, etc.)
diff --git a/.gitignore b/.gitignore
index 76fce36..2b55c68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@
 
 # goreleaser builds
 dist
+
+# makefile artifact
+sketch
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2b31b66
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,32 @@
+# Makefile for building sketch with embedded assets
+#
+# Two-layer architecture:
+# 1. Linux binary ("innie") - runs in container, embeds webui assets
+# 2. Native binary ("outie") - runs on user's machine, embeds innie
+
+BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
+COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
+VERSION := $(shell git describe --tags --dirty --always 2>/dev/null || echo "dev")
+LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(BUILD_TIME) -X main.makefile=true
+
+.PHONY: all clean help
+.PHONY: outie innie
+.PHONY: webui-assets
+
+all: outie
+
+outie: innie
+	go build -ldflags="$(LDFLAGS)" -tags=outie -o sketch ./cmd/sketch
+
+innie: webui-assets
+	CGO_ENABLED=0 GOOS=linux go build -ldflags="$(LDFLAGS)" -tags=innie -o embedded/sketch-linux/sketch-linux ./cmd/sketch
+
+webui-assets:
+	rm -rf embedded/webui-dist
+	go run ./cmd/genwebui -- embedded/webui-dist
+
+clean:
+	@echo "Cleaning build artifacts..."
+	rm -f sketch
+	rm -rf embedded/sketch-linux embedded/webui-dist
+	cd webui && rm -rf node_modules dist
diff --git a/README.md b/README.md
index 2e73713..4964714 100644
--- a/README.md
+++ b/README.md
@@ -25,9 +25,13 @@
 
 ## 📋 Quick Start
 
+Grab the most recent [nightly release](https://github.com/boldsoftware/sketch/releases).
+
+To build yourself, clone this repo, and then run:
+
 ```sh
-go install sketch.dev/cmd/sketch@latest
-sketch
+$ make
+$ ./sketch
 ```
 
 ## 🔧 Requirements
diff --git a/cmd/genwebui/genwebui.go b/cmd/genwebui/genwebui.go
new file mode 100644
index 0000000..2abe552
--- /dev/null
+++ b/cmd/genwebui/genwebui.go
@@ -0,0 +1,29 @@
+package main
+
+import (
+	"flag"
+	"log"
+	"os"
+
+	"sketch.dev/webui"
+)
+
+func main() {
+	flag.Parse()
+	if flag.NArg() != 1 {
+		log.Fatalf("expected exactly 1 arg (destination directory), got %v", flag.NArg())
+	}
+	dest := flag.Arg(0)
+	if dest == "" {
+		log.Fatalf("expected destination directory, got %q", dest)
+	}
+	// TODO: make webui.Build write directly to dest instead of writing to a temp dir and copying to dest
+	fsys, err := webui.Build()
+	if err != nil {
+		log.Fatal(err)
+	}
+	err = os.CopyFS(dest, fsys)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/cmd/genwebuizip/genwebuizip.go b/cmd/genwebuizip/genwebuizip.go
deleted file mode 100644
index 02602b6..0000000
--- a/cmd/genwebuizip/genwebuizip.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package main
-
-import (
-	"flag"
-	"fmt"
-	"log"
-	"os/exec"
-	"path/filepath"
-
-	"sketch.dev/webui"
-)
-
-func main() {
-	dest := flag.String("dest", ".", "destination directory")
-	flag.Parse()
-
-	// Make sure that the webui is built so we can copy the results to the container.
-	_, err := webui.Build()
-	if err != nil {
-		log.Fatal(err.Error())
-	}
-
-	webuiZipPath, err := webui.ZipPath()
-	if err != nil {
-		log.Fatal(err.Error())
-	}
-	cmd := exec.Command("cp", webuiZipPath, filepath.Join(*dest, "."))
-	if err := cmd.Run(); err != nil {
-		log.Fatal(err.Error())
-	}
-
-	fmt.Printf("webuiZipPath: %v copied to %s\n", webuiZipPath, *dest)
-}
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index 587a6b1..21e569b 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -38,12 +38,13 @@
 	"sketch.dev/webui"
 )
 
-// Version information set by GoReleaser at build time
+// Version information set by ldflags at build time
 var (
-	version = "dev"     // version string
-	commit  = "none"    // git commit hash
-	date    = "unknown" // build timestamp
-	builtBy = "unknown" // who built this binary
+	version  = "dev"     // version string
+	commit   = "none"    // git commit hash
+	date     = "unknown" // build timestamp
+	builtBy  = "unknown" // who built this binary
+	makefile = ""        // marker indicating a makefile build
 )
 
 func main() {
@@ -59,6 +60,11 @@
 func run() error {
 	flagArgs := parseCLIFlags()
 
+	// If not built with make, embedded assets will be missing.
+	if makefile == "" {
+		return fmt.Errorf("please use `make` to build sketch")
+	}
+
 	// Set up signal handling if -ignoresig flag is set
 	if flagArgs.ignoreSig {
 		setupSignalIgnoring()
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 7b9d549..77af08a 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -23,9 +23,9 @@
 
 	"golang.org/x/crypto/ssh"
 	"sketch.dev/browser"
+	"sketch.dev/embedded"
 	"sketch.dev/loop/server"
 	"sketch.dev/skribe"
-	"sketch.dev/webui"
 )
 
 // ContainerConfig holds all configuration for launching a container
@@ -171,14 +171,6 @@
 		return err
 	}
 
-	linuxSketchBin := config.SketchBinaryLinux
-	if linuxSketchBin == "" {
-		linuxSketchBin, err = buildLinuxSketchBin(ctx)
-		if err != nil {
-			return err
-		}
-	}
-
 	cntrName := "sketch-" + config.SessionID
 	defer func() {
 		if config.NoCleanup {
@@ -236,28 +228,12 @@
 	config.Upstream = upstream
 	config.Commit = commit
 
-	// Create the sketch container
+	// Create the sketch container, copy over linux sketch
 	if err := createDockerContainer(ctx, cntrName, hostPort, relPath, imgName, config); err != nil {
 		return fmt.Errorf("failed to create docker container: %w", err)
 	}
-
-	// Copy the sketch linux binary into the container
-	if out, err := combinedOutput(ctx, "docker", "cp", linuxSketchBin, cntrName+":/bin/sketch"); err != nil {
-		return fmt.Errorf("docker cp: %s, %w", out, err)
-	}
-
-	// Make sure that the webui is built so we can copy the results to the container.
-	_, err = webui.Build()
-	if err != nil {
-		return fmt.Errorf("failed to build webui: %w", err)
-	}
-
-	webuiZipPath, err := webui.ZipPath()
-	if err != nil {
-		return err
-	}
-	if out, err := combinedOutput(ctx, "docker", "cp", webuiZipPath, cntrName+":/root/.cache/sketch/webui/"+filepath.Base(webuiZipPath)); err != nil {
-		return fmt.Errorf("docker cp: %s, %w", out, err)
+	if err := copyEmbeddedLinuxBinaryToContainer(ctx, cntrName); err != nil {
+		return fmt.Errorf("failed to copy linux binary to container: %w", err)
 	}
 
 	fmt.Printf("📦 running in container %s\n", cntrName)
@@ -1182,6 +1158,40 @@
 	return strings.TrimSpace(string(out)), nil
 }
 
+// copyEmbeddedLinuxBinaryToContainer copies the embedded linux binary to the container
+func copyEmbeddedLinuxBinaryToContainer(ctx context.Context, containerName string) error {
+	bin := embedded.LinuxBinary()
+	if bin == nil {
+		return fmt.Errorf("nil embedded linux binary reader, did you build using `make`?")
+	}
+
+	cacheDir := filepath.Join(os.TempDir(), "sketch-binary-cache")
+	if err := os.MkdirAll(cacheDir, 0o700); err != nil {
+		return fmt.Errorf("failed to create cache directory: %w", err)
+	}
+
+	hash := sha256.Sum256(bin)
+	binaryPath := filepath.Join(cacheDir, hex.EncodeToString(hash[:]))
+	_, statErr := os.Stat(binaryPath)
+	switch {
+	case os.IsNotExist(statErr):
+		if err := os.WriteFile(binaryPath, bin, 0o700); err != nil {
+			return fmt.Errorf("failed to write binary to cache: %w", err)
+		}
+	case statErr != nil:
+		return fmt.Errorf("failed to check if cached binary exists: %w", statErr)
+	}
+	// TODO: clean up old sketch binaries from the cache dir:
+	// maybe set a max of 5, and then delete oldest after that by atime/mtime/ctime
+
+	if out, err := combinedOutput(ctx, "docker", "cp", binaryPath, containerName+":/bin/sketch"); err != nil {
+		return fmt.Errorf("docker cp failed: %s: %w", out, err)
+	}
+
+	slog.DebugContext(ctx, "copied embedded linux binary to container", "container", containerName)
+	return nil
+}
+
 const seccompProfile = `{
   "defaultAction": "SCMP_ACT_ALLOW",
   "syscalls": [
diff --git a/dockerimg/dockerimg_test.go b/dockerimg/dockerimg_test.go
index 563d33b..e15a81f 100644
--- a/dockerimg/dockerimg_test.go
+++ b/dockerimg/dockerimg_test.go
@@ -1,8 +1,12 @@
 package dockerimg
 
 import (
+	"bytes"
 	"context"
+	"crypto/sha256"
+	"encoding/hex"
 	"os"
+	"path/filepath"
 	"testing"
 )
 
@@ -103,3 +107,72 @@
 		t.Error("Expected error for nonexistent image, got nil")
 	}
 }
+
+// TestBinaryCaching tests the content-addressable binary caching functionality
+func TestBinaryCaching(t *testing.T) {
+	// Mock the embedded binary
+	testBinary := []byte("fake binary content for testing")
+
+	// Calculate expected hash
+	hash := sha256.Sum256(testBinary)
+	hashHex := hex.EncodeToString(hash[:])
+
+	// Create a temporary directory for this test
+	tempDir := t.TempDir()
+	cacheDir := filepath.Join(tempDir, "sketch-binary-cache")
+	binaryPath := filepath.Join(cacheDir, hashHex)
+
+	// First, create the cache directory
+	err := os.MkdirAll(cacheDir, 0o755)
+	if err != nil {
+		t.Fatalf("Failed to create cache directory: %v", err)
+	}
+
+	// Verify the binary doesn't exist initially
+	if _, err := os.Stat(binaryPath); !os.IsNotExist(err) {
+		t.Fatalf("Binary should not exist initially, but stat returned: %v", err)
+	}
+
+	// Write the binary (simulating first time)
+	err = os.WriteFile(binaryPath, testBinary, 0o700)
+	if err != nil {
+		t.Fatalf("Failed to write binary: %v", err)
+	}
+
+	// Verify the binary now exists and has correct permissions
+	info, err := os.Stat(binaryPath)
+	if err != nil {
+		t.Fatalf("Failed to stat cached binary: %v", err)
+	}
+
+	if info.Mode().Perm() != 0o700 {
+		t.Errorf("Expected permissions 0700, got %o", info.Mode().Perm())
+	}
+
+	// Verify content matches
+	cachedContent, err := os.ReadFile(binaryPath)
+	if err != nil {
+		t.Fatalf("Failed to read cached binary: %v", err)
+	}
+
+	if !bytes.Equal(testBinary, cachedContent) {
+		t.Error("Cached binary content doesn't match original")
+	}
+
+	// Test that the same hash produces the same path
+	hash2 := sha256.Sum256(testBinary)
+	hashHex2 := hex.EncodeToString(hash2[:])
+
+	if hashHex != hashHex2 {
+		t.Error("Same content should produce same hash")
+	}
+
+	// Test that different content produces different hash
+	differentBinary := []byte("different fake binary content")
+	differentHash := sha256.Sum256(differentBinary)
+	differentHashHex := hex.EncodeToString(differentHash[:])
+
+	if hashHex == differentHashHex {
+		t.Error("Different content should produce different hash")
+	}
+}
diff --git a/embedded/.gitignore b/embedded/.gitignore
new file mode 100644
index 0000000..2bcd878
--- /dev/null
+++ b/embedded/.gitignore
@@ -0,0 +1,2 @@
+sketch-linux
+webui-dist
diff --git a/embedded/embedded.go b/embedded/embedded.go
new file mode 100644
index 0000000..a5b8c49
--- /dev/null
+++ b/embedded/embedded.go
@@ -0,0 +1,20 @@
+//go:build !innie && !outie
+
+// Package embedded provides access to embedded assets for the sketch binary.
+// The native binary (outie) embeds only the linux binary.
+// The linux binary (innie) embeds only the webui assets.
+package embedded
+
+import (
+	"io/fs"
+)
+
+// LinuxBinary returns the embedded linux binary.
+func LinuxBinary() []byte {
+	return nil
+}
+
+// WebUIFS returns the embedded webui filesystem for direct serving
+func WebUIFS() fs.FS {
+	return nil
+}
diff --git a/embedded/embedded_innie.go b/embedded/embedded_innie.go
new file mode 100644
index 0000000..475e7a2
--- /dev/null
+++ b/embedded/embedded_innie.go
@@ -0,0 +1,23 @@
+//go:build innie
+
+package embedded
+
+import (
+	"embed"
+	"io/fs"
+)
+
+//go:embed webui-dist
+var webUIAssets embed.FS
+
+// LinuxBinary returns the embedded linux binary.
+func LinuxBinary() []byte {
+	return nil
+}
+
+// WebUIFS returns the embedded webui filesystem for direct serving
+func WebUIFS() fs.FS {
+	// TODO: can we avoid this fs.Sub somehow?
+	webuiFS, _ := fs.Sub(webUIAssets, "webui-dist")
+	return webuiFS
+}
diff --git a/embedded/embedded_outie.go b/embedded/embedded_outie.go
new file mode 100644
index 0000000..a89ff4b
--- /dev/null
+++ b/embedded/embedded_outie.go
@@ -0,0 +1,22 @@
+//go:build outie
+
+package embedded
+
+import (
+	_ "embed"
+	"io/fs"
+)
+
+//go:embed sketch-linux/sketch-linux
+var sketchLinuxBinary []byte
+
+// LinuxBinary returns the embedded linux binary.
+func LinuxBinary() []byte {
+	return sketchLinuxBinary
+}
+
+// WebUIFS returns the embedded webui filesystem.
+func WebUIFS() fs.FS {
+	// webUIAssets are not present in outie
+	return nil
+}
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 3f90f8b..e223a60 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -10,7 +10,6 @@
 	"fmt"
 	"html"
 	"io"
-	"io/fs"
 	"log/slog"
 	"net/http"
 	"net/http/httputil"
@@ -26,14 +25,13 @@
 	"syscall"
 	"time"
 
-	"sketch.dev/git_tools"
-	"sketch.dev/loop/server/gzhandler"
-
 	"github.com/creack/pty"
 	"sketch.dev/claudetool/browse"
+	"sketch.dev/embedded"
+	"sketch.dev/git_tools"
 	"sketch.dev/llm/conversation"
 	"sketch.dev/loop"
-	"sketch.dev/webui"
+	"sketch.dev/loop/server/gzhandler"
 )
 
 // terminalSession represents a terminal session with its PTY and the event channel
@@ -216,11 +214,6 @@
 		sshError:         "",
 	}
 
-	webBundle, err := webui.Build()
-	if err != nil {
-		return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
-	}
-
 	s.mux.HandleFunc("/stream", s.handleSSEStream)
 
 	// Git tool endpoints
@@ -488,7 +481,7 @@
 		}
 	})
 
-	s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
+	s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
 
 	// Terminal WebSocket handler
 	// Terminal endpoints - predefined terminals 1-9
@@ -528,33 +521,14 @@
 		s.handleTerminalInput(w, r, sessionID)
 	})
 
-	// Handler for interface selection via URL parameters (?m for mobile, ?d for desktop, auto-detect by default)
+	// Handler for interface selection via URL parameters (?m for mobile)
 	s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-		// Check URL parameters for interface selection
-		queryParams := r.URL.Query()
-
-		// Check if mobile interface is requested (?m parameter)
-		if queryParams.Has("m") {
-			// Serve the mobile-app-shell.html file
-			data, err := fs.ReadFile(webBundle, "mobile-app-shell.html")
-			if err != nil {
-				http.Error(w, "Mobile interface not found", http.StatusNotFound)
-				return
-			}
-			w.Header().Set("Content-Type", "text/html")
-			w.Write(data)
-			return
+		webuiFS := embedded.WebUIFS()
+		appShell := "sketch-app-shell.html"
+		if r.URL.Query().Has("m") {
+			appShell = "mobile-app-shell.html"
 		}
-
-		// Check if desktop interface is explicitly requested (?d parameter)
-		// or serve desktop by default
-		data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
-		if err != nil {
-			http.Error(w, "File not found", http.StatusNotFound)
-			return
-		}
-		w.Header().Set("Content-Type", "text/html")
-		w.Write(data)
+		http.ServeFileFS(w, r, webuiFS, appShell)
 	})
 
 	// Handler for /commit-description - returns the description of a git commit
diff --git a/webui/esbuild.go b/webui/esbuild.go
index d310f95..1588f06 100644
--- a/webui/esbuild.go
+++ b/webui/esbuild.go
@@ -358,19 +358,7 @@
 		return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
 	}
 
-	// Everything succeeded, so we write tmpHashDir to hashZip
-	buf := new(bytes.Buffer)
-	w := zip.NewWriter(buf)
-	if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
-		return nil, err
-	}
-	if err := w.Close(); err != nil {
-		return nil, err
-	}
-	if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
-		return nil, err
-	}
-	return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
+	return os.DirFS(tmpHashDir), nil
 }
 
 func esbuildBundle(outDir, src, metafilePath string) error {