From 8c5b70a19521508a290f634b583330803f955215 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Mon, 27 Jul 2026 00:52:01 +0530
Subject: [PATCH 01/16] feat(spawner): server-hosted agents with identity
 relocation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

buzz-spawner daemon runs agents in containers, attested via the
NIP-AS kind:24201 encrypted handshake. Agents can relocate from the
desktop to a spawner keeping their pubkey, channels, profile, and
NIP-AE memory.

Relocation is persisted as state, not just performed as an action:
the hand-off marks relocated_to_spawner on the ManagedAgentRecord
(publish -> mark -> stop), and every local start path — manual start,
app-launch restore, runtime reconcile, lazy @mention wake, and the
auto-restart policy — refuses a relocated identity. Without this the
auto-restart loop resurrected the local copy after hand-off, running
one identity in two places (duplicate replies, double-billed turns).

Mentions of a relocated agent skip the local wake entirely; the
server copy sees them through the relay. The Agents screen shows a
server badge instead of Start for relocated agents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
---
 Cargo.lock                                    |   98 +-
 Cargo.toml                                    |    1 +
 Dockerfile.acp                                |  106 ++
 Dockerfile.spawner                            |   43 +
 ISSUE-buzz-spawner.md                         |  107 ++
 crates/buzz-core/src/kind.rs                  |  142 ++-
 crates/buzz-relay/src/handlers/auth.rs        |   11 +
 crates/buzz-relay/src/handlers/event.rs       |  163 ++-
 crates/buzz-relay/src/handlers/ingest.rs      |    8 +
 crates/buzz-relay/src/handlers/req.rs         |   46 +-
 crates/buzz-relay/src/state.rs                |   26 +
 crates/buzz-sdk/src/lib.rs                    |    1 +
 crates/buzz-sdk/src/spawner.rs                | 1018 +++++++++++++++++
 crates/buzz-spawner/Cargo.toml                |   36 +
 crates/buzz-spawner/examples/owner_sim.rs     |  322 ++++++
 crates/buzz-spawner/src/attestation.rs        |  396 +++++++
 crates/buzz-spawner/src/config.rs             |  230 ++++
 crates/buzz-spawner/src/container.rs          |  398 +++++++
 crates/buzz-spawner/src/daemon.rs             |  851 ++++++++++++++
 crates/buzz-spawner/src/env.rs                |  376 ++++++
 crates/buzz-spawner/src/lib.rs                |   50 +
 crates/buzz-spawner/src/main.rs               |   38 +
 crates/buzz-spawner/src/reconcile.rs          |  727 ++++++++++++
 crates/buzz-spawner/src/relay.rs              |  360 ++++++
 crates/buzz-spawner/src/store.rs              |  282 +++++
 deploy/compose/.env.example                   |   17 +
 deploy/compose/README.md                      |   62 +
 deploy/compose/compose.spawner.yml            |   72 ++
 deploy/compose/run.sh                         |    7 +
 desktop/playwright.config.ts                  |    1 +
 .../src-tauri/src/commands/agent_config.rs    |    1 +
 .../src-tauri/src/commands/agent_settings.rs  |   52 +
 desktop/src-tauri/src/commands/agents.rs      |    1 +
 .../src-tauri/src/commands/agents_tests.rs    |    1 +
 desktop/src-tauri/src/commands/mod.rs         |    2 +
 .../commands/personas/delete_cascade_tests.rs |    1 +
 .../personas/inbound/inbound_tests.rs         |    1 +
 .../src/commands/personas/snapshot/import.rs  |    1 +
 .../src/commands/personas/snapshot/tests.rs   |    1 +
 .../personas/update/name_propagation_tests.rs |    1 +
 desktop/src-tauri/src/commands/spawner.rs     |  324 ++++++
 .../src-tauri/src/commands/team_snapshot.rs   |    1 +
 .../src/commands/team_snapshot/tests.rs       |    1 +
 desktop/src-tauri/src/lib.rs                  |    3 +
 .../src/managed_agents/agent_events.rs        |    1 +
 .../src/managed_agents/agent_snapshot.rs      |    1 +
 .../config_bridge/reader_tests.rs             |    1 +
 .../src/managed_agents/discovery/tests.rs     |    1 +
 .../src/managed_agents/global_config/tests.rs |    1 +
 .../src/managed_agents/nest/tests.rs          |    1 +
 .../src-tauri/src/managed_agents/readiness.rs |    1 +
 .../src-tauri/src/managed_agents/restore.rs   |    8 +-
 .../src-tauri/src/managed_agents/runtime.rs   |    7 +
 .../src/managed_agents/runtime/tests.rs       |    1 +
 .../src/managed_agents/runtime_commands.rs    |   10 +-
 .../src/managed_agents/spawn_hash/tests.rs    |    1 +
 .../src-tauri/src/managed_agents/storage.rs   |   15 +
 .../src/managed_agents/storage_tests.rs       |   35 +
 .../src/managed_agents/team_snapshot.rs       |    1 +
 .../src/managed_agents/teams_tests.rs         |    1 +
 desktop/src-tauri/src/managed_agents/types.rs |   12 +
 desktop/src/app/AppShell.tsx                  |    6 +
 .../features/agents/agentLocation.test.mjs    |   82 ++
 desktop/src/features/agents/agentLocation.ts  |  157 +++
 .../features/agents/agentRelocation.test.mjs  |   80 ++
 .../src/features/agents/agentRelocation.ts    |   79 ++
 .../agents/lib/autoRestartPolicy.test.mjs     |    5 +
 .../features/agents/lib/autoRestartPolicy.ts  |    5 +
 .../agents/lib/useAutoRestartPolicy.ts        |    2 +
 .../agents/spawnerAttestationStore.ts         |  325 ++++++
 .../features/agents/spawnerDirectoryStore.ts  |  115 ++
 .../agents/spawnerPreference.test.mjs         |   71 ++
 .../src/features/agents/spawnerPreference.ts  |  171 +++
 .../src/features/agents/spawnerStatusStore.ts |  152 +++
 .../src/features/agents/trustedSpawners.ts    |  115 ++
 .../src/features/agents/ui/AgentDialog.tsx    |   41 +-
 .../features/agents/ui/AgentRunsOnSection.tsx |   71 ++
 .../agents/ui/AgentRuntimeAvatarControl.tsx   |   18 +-
 desktop/src/features/agents/ui/AgentsView.tsx |    3 +
 .../agents/ui/MoveAgentToServerMenu.tsx       |   73 ++
 .../features/agents/ui/PersonaActionsMenu.tsx |    2 +
 .../agents/ui/RequestedAgentCreateDialogs.tsx |   10 +-
 .../agents/ui/ServerAgentsSection.tsx         |  493 ++++++++
 .../agents/ui/SpawnerAttestationDialog.tsx    |  217 ++++
 .../agents/ui/UnifiedAgentsSection.tsx        |    2 +
 .../agents/ui/agentLocationOptions.test.mjs   |   60 +
 .../agents/ui/agentLocationOptions.ts         |   71 ++
 .../agents/ui/spawnerAttestationCopy.test.mjs |   92 ++
 .../agents/ui/useDeployPersonaToSpawner.ts    |   39 +
 .../features/agents/ui/usePersonaActions.ts   |   15 +
 .../src/features/agents/useAgentManagement.ts |   11 +-
 .../src/features/agents/useServerAgents.ts    |  216 ++++
 .../features/agents/useSpawnerIngestion.ts    |   58 +
 .../features/communities/useCommunityInit.ts  |   10 +
 .../messages/ui/useMentionSendFlow.ts         |    8 +
 desktop/src/shared/api/spawnerRelay.test.mjs  |  167 +++
 desktop/src/shared/api/spawnerRelay.ts        |  332 ++++++
 desktop/src/shared/api/tauri.ts               |    2 +
 desktop/src/shared/api/tauriManagedAgents.ts  |   21 +
 desktop/src/shared/api/tauriSpawner.ts        |  101 ++
 desktop/src/shared/api/types.ts               |    3 +
 desktop/src/shared/constants/kinds.ts         |   14 +
 .../e2e/server-agents-screenshots.spec.ts     |  138 +++
 run-dev.command                               |    8 +
 104 files changed, 10117 insertions(+), 28 deletions(-)
 create mode 100644 Dockerfile.acp
 create mode 100644 Dockerfile.spawner
 create mode 100644 ISSUE-buzz-spawner.md
 create mode 100644 crates/buzz-sdk/src/spawner.rs
 create mode 100644 crates/buzz-spawner/Cargo.toml
 create mode 100644 crates/buzz-spawner/examples/owner_sim.rs
 create mode 100644 crates/buzz-spawner/src/attestation.rs
 create mode 100644 crates/buzz-spawner/src/config.rs
 create mode 100644 crates/buzz-spawner/src/container.rs
 create mode 100644 crates/buzz-spawner/src/daemon.rs
 create mode 100644 crates/buzz-spawner/src/env.rs
 create mode 100644 crates/buzz-spawner/src/lib.rs
 create mode 100644 crates/buzz-spawner/src/main.rs
 create mode 100644 crates/buzz-spawner/src/reconcile.rs
 create mode 100644 crates/buzz-spawner/src/relay.rs
 create mode 100644 crates/buzz-spawner/src/store.rs
 create mode 100644 deploy/compose/compose.spawner.yml
 create mode 100644 desktop/src-tauri/src/commands/spawner.rs
 create mode 100644 desktop/src/features/agents/agentLocation.test.mjs
 create mode 100644 desktop/src/features/agents/agentLocation.ts
 create mode 100644 desktop/src/features/agents/agentRelocation.test.mjs
 create mode 100644 desktop/src/features/agents/agentRelocation.ts
 create mode 100644 desktop/src/features/agents/spawnerAttestationStore.ts
 create mode 100644 desktop/src/features/agents/spawnerDirectoryStore.ts
 create mode 100644 desktop/src/features/agents/spawnerPreference.test.mjs
 create mode 100644 desktop/src/features/agents/spawnerPreference.ts
 create mode 100644 desktop/src/features/agents/spawnerStatusStore.ts
 create mode 100644 desktop/src/features/agents/trustedSpawners.ts
 create mode 100644 desktop/src/features/agents/ui/AgentRunsOnSection.tsx
 create mode 100644 desktop/src/features/agents/ui/MoveAgentToServerMenu.tsx
 create mode 100644 desktop/src/features/agents/ui/ServerAgentsSection.tsx
 create mode 100644 desktop/src/features/agents/ui/SpawnerAttestationDialog.tsx
 create mode 100644 desktop/src/features/agents/ui/agentLocationOptions.test.mjs
 create mode 100644 desktop/src/features/agents/ui/agentLocationOptions.ts
 create mode 100644 desktop/src/features/agents/ui/spawnerAttestationCopy.test.mjs
 create mode 100644 desktop/src/features/agents/ui/useDeployPersonaToSpawner.ts
 create mode 100644 desktop/src/features/agents/useServerAgents.ts
 create mode 100644 desktop/src/features/agents/useSpawnerIngestion.ts
 create mode 100644 desktop/src/shared/api/spawnerRelay.test.mjs
 create mode 100644 desktop/src/shared/api/spawnerRelay.ts
 create mode 100644 desktop/src/shared/api/tauriSpawner.ts
 create mode 100644 desktop/tests/e2e/server-agents-screenshots.spec.ts
 create mode 100755 run-dev.command

diff --git a/Cargo.lock b/Cargo.lock
index 49104b22d2..ea70a56f15 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -718,6 +718,49 @@ version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc"
 
+[[package]]
+name = "bollard"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9d0a013e3d3ee4edd61e779adf117944c08902d375f18630a0c5b8f95659734"
+dependencies = [
+ "base64",
+ "bollard-stubs",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "http",
+ "http-body-util",
+ "hyper",
+ "hyper-named-pipe",
+ "hyper-util",
+ "hyperlocal",
+ "log",
+ "pin-project-lite",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "serde_urlencoded",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-util",
+ "tower-service",
+ "url",
+ "winapi",
+]
+
+[[package]]
+name = "bollard-stubs"
+version = "1.53.1-rc.29.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serde_repr",
+]
+
 [[package]]
 name = "bon"
 version = "3.9.1"
@@ -1234,6 +1277,30 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "buzz-spawner"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "bollard",
+ "buzz-core",
+ "buzz-sdk",
+ "buzz-ws-client",
+ "chrono",
+ "futures-util",
+ "hex",
+ "nostr",
+ "rand 0.10.1",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
 [[package]]
 name = "buzz-test-client"
 version = "0.1.0"
@@ -2177,7 +2244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
 dependencies = [
  "data-encoding",
- "syn 1.0.109",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -3526,6 +3593,20 @@ dependencies = [
  "want",
 ]
 
+[[package]]
+name = "hyper-named-pipe"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf"
+dependencies = [
+ "hex",
+ "hyper",
+ "hyper-util",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
 [[package]]
 name = "hyper-rustls"
 version = "0.27.9"
@@ -3597,6 +3678,21 @@ dependencies = [
  "windows-registry",
 ]
 
+[[package]]
+name = "hyperlocal"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
+dependencies = [
+ "hex",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
 [[package]]
 name = "iana-time-zone"
 version = "0.1.65"
diff --git a/Cargo.toml b/Cargo.toml
index 3ac7ee4cce..37b49b95ec 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,7 @@
 [workspace]
 members = [
     "crates/buzz-relay",
+    "crates/buzz-spawner",
     "crates/buzz-core",
     "crates/buzz-conformance",
     "crates/buzz-push-gateway",
diff --git a/Dockerfile.acp b/Dockerfile.acp
new file mode 100644
index 0000000000..91d7acdfcd
--- /dev/null
+++ b/Dockerfile.acp
@@ -0,0 +1,106 @@
+# syntax=docker/dockerfile:1.7
+#
+# The agent runtime image: one `buzz-acp` harness per container, spawned by
+# `buzz-spawner`. This is the server-side equivalent of what the desktop app
+# launches locally via `managed_agents/runtime.rs::spawn_agent_child`.
+#
+# `sprig` is a multicall binary (crates/sprig/src/main.rs) that dispatches on
+# argv[0], so one build produces the harness, the built-in agent, and the dev
+# MCP tools. The symlinks below are how the harness finds them — it spawns its
+# ACP agent by name over stdio.
+ARG RUST_VERSION=1.95
+ARG DEBIAN_VERSION=bookworm
+
+FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
+RUN cargo install cargo-chef --locked --version 0.1.71
+WORKDIR /build
+
+FROM chef AS planner
+COPY . .
+RUN cargo chef prepare --recipe-path recipe.json
+
+FROM chef AS builder
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+COPY --from=planner /build/recipe.json recipe.json
+RUN cargo chef cook --release --recipe-path recipe.json
+COPY . .
+RUN cargo build --release --locked -p sprig --bin sprig \
+    && cargo build --release --locked -p buzz-cli --bin buzz \
+    && strip target/release/sprig target/release/buzz
+
+FROM debian:${DEBIAN_VERSION}-slim AS runtime
+LABEL org.opencontainers.image.title="Buzz ACP Agent" \
+      org.opencontainers.image.description="buzz-acp harness runtime for server-hosted Buzz agents" \
+      org.opencontainers.image.source="https://github.com/block/buzz" \
+      org.opencontainers.image.licenses="Apache-2.0"
+
+# git and ca-certificates are not optional: agents clone and push repositories
+# through the relay's smart-HTTP transport, authenticated by git-credential-nostr.
+#
+# node and curl exist for the optional Claude Code runtime below: the ACP adapter
+# ships as an npm package and the CLI installs via a shell script.
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends ca-certificates curl git \
+    && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
+    && apt-get install -y --no-install-recommends nodejs \
+    && rm -rf /var/lib/apt/lists/* \
+    && groupadd --system --gid 1000 agent \
+    && useradd --system --uid 1000 --gid 1000 --home-dir /home/agent --create-home --shell /bin/bash agent
+
+COPY --from=builder /build/target/release/sprig /usr/local/bin/sprig
+COPY --from=builder /build/target/release/buzz /usr/local/bin/buzz
+
+# Multicall dispatch: sprig routes on argv[0].
+RUN for name in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr git-sign-nostr; do \
+        ln -s /usr/local/bin/sprig "/usr/local/bin/$name"; \
+    done
+
+# Claude Code and its ACP adapter, for running agents on a Claude subscription
+# instead of a metered API key.
+#
+# # Authentication in a container
+#
+# The subscription flow is browser-based and interactive, which a container
+# cannot do. The non-interactive equivalent is a long-lived token from
+# `claude setup-token` (run once on a machine with a browser), supplied as
+# `CLAUDE_CODE_OAUTH_TOKEN`. `buzz-acp` does not clear the environment when it
+# spawns the adapter, so the token reaches the CLI unchanged.
+#
+# Forward it with the spawner's own passthrough, which takes variable *names*:
+#
+#   BUZZ_SPAWNER_AGENT_ENV=CLAUDE_CODE_OAUTH_TOKEN
+#   BUZZ_SPAWNER_AGENT_COMMAND=claude-agent-acp
+#
+# The alternative — bind-mounting a pre-authenticated `~/.claude` — is not
+# supported by the spawner and would share one credential store across every
+# agent on the host.
+ARG CLAUDE_ACP_VERSION=0.62.0
+# The installer takes only [stable|latest|VERSION] and always installs into
+# $HOME/.local/bin, so it runs with HOME pointed at the agent's home (this RUN
+# is root, whose $HOME the agent user cannot read) and the result is symlinked
+# onto PATH. `HOME=... bash` rather than `HOME=... curl`: the variable has to
+# reach the shell that does the installing, not the download.
+RUN npm install -g "@agentclientprotocol/claude-agent-acp@${CLAUDE_ACP_VERSION}" \
+    && npm cache clean --force \
+    && curl -fsSL https://claude.ai/install.sh | HOME=/home/agent bash -s -- stable \
+    && ln -sf /home/agent/.local/bin/claude /usr/local/bin/claude \
+    && chown -R agent:agent /home/agent \
+    && /usr/local/bin/claude --version
+
+# The nest. buzz-spawner mounts a per-agent named volume here, so an agent's
+# workspace survives restarts and config changes but is invisible to every
+# other agent on the host.
+#
+# `.claude` is created up front and owned by the agent: the CLI writes state
+# there, and it must not fall back to a root-owned path or a read-only home.
+RUN mkdir -p /home/agent/.buzz /home/agent/.claude && chown -R agent:agent /home/agent
+
+USER agent:agent
+WORKDIR /home/agent/.buzz
+ENV HOME=/home/agent
+# Default to the in-house agent, which needs only an API key. Override with
+# BUZZ_SPAWNER_AGENT_COMMAND=claude-agent-acp to use a Claude subscription.
+ENV BUZZ_ACP_AGENT_COMMAND=buzz-agent
+ENTRYPOINT ["/usr/local/bin/buzz-acp"]
diff --git a/Dockerfile.spawner b/Dockerfile.spawner
new file mode 100644
index 0000000000..3761d993fc
--- /dev/null
+++ b/Dockerfile.spawner
@@ -0,0 +1,43 @@
+# syntax=docker/dockerfile:1.7
+ARG RUST_VERSION=1.95
+ARG DEBIAN_VERSION=bookworm
+
+FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
+RUN cargo install cargo-chef --locked --version 0.1.71
+WORKDIR /build
+
+FROM chef AS planner
+COPY . .
+RUN cargo chef prepare --recipe-path recipe.json
+
+FROM chef AS builder
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+COPY --from=planner /build/recipe.json recipe.json
+RUN cargo chef cook --release --recipe-path recipe.json
+COPY . .
+RUN cargo build --release --locked -p buzz-spawner --bin buzz-spawner \
+    && strip target/release/buzz-spawner
+
+FROM debian:${DEBIAN_VERSION}-slim AS runtime
+LABEL org.opencontainers.image.title="Buzz Spawner" \
+      org.opencontainers.image.description="Reconciles Nostr agent specs into Docker-isolated buzz-acp containers" \
+      org.opencontainers.image.source="https://github.com/block/buzz" \
+      org.opencontainers.image.licenses="Apache-2.0"
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends ca-certificates \
+    && rm -rf /var/lib/apt/lists/* \
+    && groupadd --system --gid 1000 buzz \
+    && useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz-spawner --create-home --shell /usr/sbin/nologin buzz
+COPY --from=builder /build/target/release/buzz-spawner /usr/local/bin/buzz-spawner
+
+# Runs as root, unlike the relay and push gateway. Talking to the Docker socket
+# requires membership in the host's docker group, whose GID varies per host, so
+# baking a fixed GID here would break on most machines. The socket mount is
+# already root-equivalent on the host (see deploy/compose/README.md) — dropping
+# to a non-root user inside the container would not change that, only obscure
+# it. An operator who wants real privilege reduction should point the spawner at
+# a rootless Docker or Podman socket via DOCKER_HOST.
+WORKDIR /var/lib/buzz-spawner
+ENTRYPOINT ["/usr/local/bin/buzz-spawner"]
diff --git a/ISSUE-buzz-spawner.md b/ISSUE-buzz-spawner.md
new file mode 100644
index 0000000000..056c36e5ba
--- /dev/null
+++ b/ISSUE-buzz-spawner.md
@@ -0,0 +1,107 @@
+# feat(spawner): first-party server-hosted agents via a buzz-spawner daemon
+
+## Problem
+
+Buzz agents can only be spawned by the desktop app.
+`desktop/src-tauri/src/managed_agents/runtime.rs::spawn_agent_child` generates
+the keypair, computes the NIP-OA owner attestation, and launches the `buzz-acp`
+harness locally. Fizz/Honey/Bumble aren't special processes — they're built-in
+personas (`managed_agents/personas.rs`) minted through that same local path.
+
+This means:
+
+- **Agents die with the laptop.** There is no always-on agent.
+- **Agent creation requires the desktop app.** Mobile and web can't create one.
+- **Self-hosters have no server-side option.** You can host a relay, but not an
+  agent. The only server-side path is `BackendKind::Provider` → an external
+  `buzz-backend-<id>` binary (`managed_agents/backend.rs:20-120`), which has no
+  OSS implementation and still puts the desktop in the control loop.
+- **The relay is entirely agent-agnostic** — no spawner, no agent registry, no
+  lifecycle hooks. Agents are just pubkeys admitted via NIP-OA owner attestation
+  (`crates/buzz-relay/src/api/mod.rs:40-200`).
+
+## Proposal
+
+Add `crates/buzz-spawner` — a standalone daemon that watches the relay for
+agent-definition events and reconciles them into Docker-isolated `buzz-acp`
+containers. It ships as a second service in `deploy/compose`.
+
+It is deliberately **not** a relay module: the relay's only subprocess today is
+`git`, and it goes out of its way to disable repo hooks
+(`api/git/transport.rs:930-935`). Adding container execution to the public
+WebSocket server isn't acceptable, and isn't necessary. `buzz-pair-relay` and
+`buzz-push-gateway` are existing precedent for sidecar service crates.
+
+The daemon is a plain relay client with its own Nostr identity. It never touches
+Postgres.
+
+```
+owner client ──kind:30178 (spec)──►  relay  ──sub──►  buzz-spawner
+     ▲                                                     │
+     └──kind:24201 attestation handshake (NIP-44)──────────┤
+                                                           ▼
+                                              Docker: buzz-acp container
+                                                           │
+                                                    (agent's own key)
+                                                           ▼
+                                                         relay
+```
+
+## Design points
+
+**Three new event kinds** (`buzz-core/src/kind.rs`), following the existing
+30175/30176/30177 agent block:
+
+| Kind | Purpose |
+|---|---|
+| `30178` `SPAWNER_AGENT_SPEC` | Desired state, owner-authored. `d` = spec slug |
+| `30179` `SPAWNER_AGENT_STATUS` | Actual state, spawner-authored. Phase, agent pubkey, last error |
+| `24201` `SPAWNER_ATTESTATION` | Ephemeral, NIP-44, `#p`-gated — the handshake |
+
+Specs follow the strict opt-IN projection discipline documented in
+`managed_agents/agent_events.rs` — the type must be physically incapable of
+carrying an nsec, auth tag, or env blob.
+
+**Attestation is a two-round handshake.** The nsec is minted on the VPS and never
+leaves it. But NIP-OA's tag is
+`Schnorr(SHA256("nostr:agent-auth:" || agent_pubkey || ":" || conditions), owner_secret)`
+(`buzz-sdk/src/nip_oa.rs`) — it binds a specific agent pubkey and needs the
+*owner's* secret key. So: owner publishes a spec → spawner mints keys and sends
+the new pubkey + nonce → owner's client signs via the existing `compute_auth_tag`
+and replies → spawner boots the container. Clients prompt on first handshake with
+a given spawner pubkey and auto-sign thereafter; never auto-sign for an unknown
+spawner.
+
+**One Docker container per agent.** Agents run shell and file-edit tools through
+`buzz-dev-mcp` — that's arbitrary code execution, so shared-host subprocesses
+aren't acceptable. Env is assembled exactly as `spawn_agent_child` does today,
+honoring the reserved-key strip list in `managed_agents/env_vars.rs:60`. LLM
+credentials come from spawner-level env and never appear in any event.
+
+**Personas resolve from `kind:30175`,** so a VPS Fizz is byte-identical to a local
+one. One access gap to close: `30175` is author-only unless `["shared","true"]`,
+and the spawner isn't the author — extend the read-gate using the same
+owner-delegation shape the relay already uses for `24200` observer frames
+(`ingest.rs:2029`), rather than inventing a second auth concept.
+
+**Reconciliation** mirrors the desktop's `reconcile.rs`/`runtime/sweep.rs`: diff
+specs against containers labelled `com.buzz.agent`, recreate on spec-hash drift
+(reusing the `spawn_hash` idea), exponential backoff on crash loops with a cap,
+and report failures via `30179` instead of retrying silently.
+
+## Scope
+
+- New: `crates/buzz-spawner/`, `docker/buzz-acp.Dockerfile`
+- Modified: `buzz-core/src/kind.rs`, `buzz-relay` ingest gates + persona
+  read-gate, `buzz-sdk/src/builders.rs`, `buzz-cli`
+  (`spawner list|logs|restart`), desktop attestation responder,
+  `deploy/compose/*`
+- Requires `BUZZ_ALLOW_NIP_OA_AUTH=true` (currently defaults false,
+  `buzz-relay/src/config.rs:172-184`)
+
+## Open item for reviewers
+
+Mounting `/var/run/docker.sock` into the spawner is root-equivalent on the host.
+This needs to be documented loudly in `deploy/compose/README.md`, and it's worth
+discussing whether a rootless-Docker or Podman-socket variant should be the
+default recommendation.
diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs
index afec52305a..47e5295523 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -203,12 +203,40 @@ pub fn is_persona_shared_kind(kind: u32) -> bool {
 /// reach foreign readers; stripping them at the author-only layer would break
 /// the catalog query.
 pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool {
+    is_unshared_persona_event_for(event, requester_pubkey_bytes, None)
+}
+
+/// Owner-delegated variant of [`is_unshared_persona_event`].
+///
+/// `requester_owner_pubkey_bytes` is the requester's NIP-OA attesting owner, as
+/// recorded in `users.agent_owner_pubkey` when the requester authenticated with
+/// an `auth` tag. An attested reader may read its own owner's unshared personas.
+///
+/// This is what lets a `buzz-spawner` daemon (or any owner-attested agent)
+/// resolve the system prompt for an agent it runs on the owner's behalf, without
+/// forcing the owner to mark every persona `["shared","true"]` — which would
+/// expose those prompts to the whole community. It reuses the same delegation
+/// the relay already applies to kind:24200 observer frames rather than
+/// introducing a second authorization concept.
+///
+/// Delegation is one hop and one direction: an owner does NOT gain read access
+/// to personas authored by agents they own.
+pub fn is_unshared_persona_event_for(
+    event: &nostr::Event,
+    requester_pubkey_bytes: &[u8],
+    requester_owner_pubkey_bytes: Option<&[u8]>,
+) -> bool {
     let kind = event.kind.as_u16() as u32;
     if !is_persona_shared_kind(kind) {
         return false;
     }
+    let author_bytes = event.pubkey.to_bytes();
     // Author reads are always allowed.
-    if event.pubkey.to_bytes() == requester_pubkey_bytes {
+    if author_bytes == requester_pubkey_bytes {
+        return false;
+    }
+    // An owner-attested requester may read its own owner's personas.
+    if requester_owner_pubkey_bytes.is_some_and(|owner| author_bytes == owner) {
         return false;
     }
     // Foreign reader: allowed only if the event is explicitly shared.
@@ -258,6 +286,50 @@ pub const KIND_TEAM: u32 = 30176;
 /// since these events are world-readable on the relay.
 pub const KIND_MANAGED_AGENT: u32 = 30177;
 
+/// NIP-AS: Spawner Announcement (replaceable, spawner-authored).
+///
+/// How an owner finds a spawner without reading a server log. A `buzz-spawner`
+/// publishes one of these — keyed by `(pubkey, kind)`, so there is exactly one
+/// per spawner — describing itself and its current capacity. Clients list them
+/// so the user can pick a spawner instead of pasting 64 hex characters.
+///
+/// # An announcement is advertising, not authorization
+///
+/// Anyone can publish one. Appearing in a client's list must never imply the
+/// spawner is trusted: an owner still signs a NIP-OA attestation per agent
+/// (kind [`KIND_SPAWNER_ATTESTATION`]), and that signature is the only thing
+/// that grants an agent access. Treat the content as self-reported hints —
+/// capacity numbers included.
+pub const KIND_SPAWNER_ANNOUNCEMENT: u32 = 10180;
+
+/// NIP-AS: Spawner Agent Spec (parameterized replaceable, owner-authored).
+///
+/// Desired state for a server-hosted agent, published by the owner and consumed
+/// by a `buzz-spawner` daemon. Addressed by `(pubkey, kind, d_tag)` where
+/// `d_tag` is a client-chosen stable spec slug — *not* the agent pubkey, which
+/// does not exist until the spawner mints it.
+///
+/// Like [`KIND_MANAGED_AGENT`], the content is an explicit opt-IN allowlist
+/// projection and these events are world-readable: it MUST never carry a secret
+/// key, NIP-OA auth tag, env vars, or provider config. The system prompt is
+/// resolved through the referenced [`KIND_PERSONA`], never inlined here.
+///
+/// Deleting the spec (NIP-09) is the signal to tear the agent down.
+pub const KIND_SPAWNER_AGENT_SPEC: u32 = 30178;
+
+/// NIP-AS: Spawner Agent Status (parameterized replaceable, spawner-authored).
+///
+/// Actual state reported back by the spawner for a given
+/// [`KIND_SPAWNER_AGENT_SPEC`]. Addressed by `(pubkey, kind, d_tag)` where
+/// `d_tag` is the spec slug it reconciles, so a spec and its status share a
+/// slug but differ in author.
+///
+/// Content carries the reconciliation phase, the minted agent pubkey once it
+/// exists, and a human-readable error when the phase is failed. The relay
+/// accepts this kind only from the pubkey the referenced spec designates as its
+/// spawner — owners cannot forge status for their own specs.
+pub const KIND_SPAWNER_AGENT_STATUS: u32 = 30179;
+
 // NIP-56 reporting
 /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984).
 ///
@@ -407,6 +479,14 @@ pub const KIND_PAIRING: u32 = 24134;
 pub const KIND_TYPING_INDICATOR: u32 = 20002;
 /// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame.
 pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200;
+/// NIP-AS: Ephemeral spawner attestation handshake frame, NIP-44 encrypted and
+/// `#p`-gated to the counterparty.
+///
+/// Carries the two-round NIP-OA attestation exchange between a `buzz-spawner`
+/// and an agent owner: spawner → owner announces a freshly minted agent pubkey
+/// plus a nonce, owner → spawner returns the signed auth tag. Ephemeral so the
+/// auth tag never lands in relay storage.
+pub const KIND_SPAWNER_ATTESTATION: u32 = 24201;
 /// Ephemeral: huddle emoji reaction burst. Channel-scoped to the ephemeral
 /// huddle channel with an `h` tag; never stored in the timeline.
 pub const KIND_HUDDLE_REACTION: u32 = 24810;
@@ -586,6 +666,10 @@ pub const ALL_KINDS: &[u32] = &[
     KIND_PERSONA,
     KIND_TEAM,
     KIND_MANAGED_AGENT,
+    KIND_SPAWNER_ANNOUNCEMENT,
+    KIND_SPAWNER_AGENT_SPEC,
+    KIND_SPAWNER_AGENT_STATUS,
+    KIND_SPAWNER_ATTESTATION,
     KIND_REPORT,
     KIND_PRODUCT_FEEDBACK,
     KIND_NIP29_PUT_USER,
@@ -784,6 +868,10 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–
 const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999
 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999
 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999
+const _: () = assert!(is_replaceable(KIND_SPAWNER_ANNOUNCEMENT)); // 10180 ∈ 10000–19999
+const _: () = assert!(is_parameterized_replaceable(KIND_SPAWNER_AGENT_SPEC)); // 30178 ∈ 30000–39999
+const _: () = assert!(is_parameterized_replaceable(KIND_SPAWNER_AGENT_STATUS)); // 30179 ∈ 30000–39999
+const _: () = assert!(is_ephemeral(KIND_SPAWNER_ATTESTATION)); // 24201 ∈ 20000–29999
 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
@@ -927,6 +1015,58 @@ mod tests {
         assert!(!is_unshared_persona_event(&ev, &author_bytes));
     }
 
+    #[test]
+    fn is_unshared_persona_event_attested_reader_reads_its_owners_persona() {
+        // A spawner attested to the persona author may read the unshared
+        // persona — this is what lets it resolve a system prompt without the
+        // owner publishing the prompt to the whole community.
+        use nostr::{EventBuilder, Keys, Kind, Tag};
+        let owner = Keys::generate();
+        let spawner = Keys::generate();
+        let ev = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "")
+            .tags(vec![Tag::parse(["d", "builtin:fizz"]).unwrap()])
+            .sign_with_keys(&owner)
+            .unwrap();
+
+        let spawner_bytes = spawner.public_key().to_bytes();
+        let owner_bytes = owner.public_key().to_bytes();
+
+        // Without the attestation the spawner is an ordinary foreign reader.
+        assert!(is_unshared_persona_event(&ev, &spawner_bytes));
+        assert!(!is_unshared_persona_event_for(
+            &ev,
+            &spawner_bytes,
+            Some(&owner_bytes)
+        ));
+    }
+
+    #[test]
+    fn is_unshared_persona_event_delegation_is_one_hop_and_one_direction() {
+        use nostr::{EventBuilder, Keys, Kind, Tag};
+        let owner = Keys::generate();
+        let agent = Keys::generate();
+        let stranger = Keys::generate();
+
+        // Persona authored by the agent, read by its owner: delegation runs
+        // reader→owner, not owner→agent, so this stays blocked.
+        let ev = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "")
+            .tags(vec![Tag::parse(["d", "agent-authored"]).unwrap()])
+            .sign_with_keys(&agent)
+            .unwrap();
+        assert!(is_unshared_persona_event_for(
+            &ev,
+            &owner.public_key().to_bytes(),
+            None
+        ));
+
+        // Being attested to somebody else grants nothing here.
+        assert!(is_unshared_persona_event_for(
+            &ev,
+            &stranger.public_key().to_bytes(),
+            Some(&owner.public_key().to_bytes())
+        ));
+    }
+
     #[test]
     fn is_unshared_persona_event_foreign_no_tag() {
         let ev = make_persona_event(&[&["d", "my-agent"]]);
diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs
index 127f1fc40e..d5c33cf1d9 100644
--- a/crates/buzz-relay/src/handlers/auth.rs
+++ b/crates/buzz-relay/src/handlers/auth.rs
@@ -275,10 +275,21 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
             }
 
             info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful");
+            let owner_bytes = auth_ctx
+                .agent_owner_pubkey
+                .as_ref()
+                .map(|owner| owner.to_bytes().to_vec());
             *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
             state
                 .conn_manager
                 .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec());
+            // Mirror the attested owner onto the connection entry so the
+            // synchronous fan-out gates can honor NIP-OA delegation. Always
+            // written, including the `None` case, so a re-auth on this socket
+            // cannot inherit a previous identity's delegation.
+            state
+                .conn_manager
+                .set_agent_owner_pubkey(conn_id, owner_bytes);
             conn.send(RelayMessage::ok(&event_id_hex, true, ""));
         }
         Err(e) => {
diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs
index 88dd5f5180..57bd9fa450 100644
--- a/crates/buzz-relay/src/handlers/event.rs
+++ b/crates/buzz-relay/src/handlers/event.rs
@@ -7,8 +7,8 @@ use tracing::{debug, error, info, warn};
 
 use buzz_core::event::StoredEvent;
 use buzz_core::kind::{
-    event_kind_u32, is_ephemeral, is_unshared_persona_event, AUTHOR_ONLY_KINDS,
-    KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE,
+    event_kind_u32, is_ephemeral, is_unshared_persona_event_for, AUTHOR_ONLY_KINDS,
+    KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, KIND_SPAWNER_ATTESTATION,
 };
 use buzz_core::observer::{
     content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
@@ -166,8 +166,11 @@ pub async fn filter_fanout_by_access(
                 if pk == author {
                     return true;
                 }
-                // Foreign connection: allowed only if the event is shared.
-                !is_unshared_persona_event(&stored_event.event, &pk)
+                // Foreign connection: allowed if the event is shared, or if this
+                // connection is NIP-OA attested to the persona's author (an
+                // owner-run agent or spawner reading its owner's personas).
+                let owner = state.conn_manager.agent_owner_for_conn(*conn_id);
+                !is_unshared_persona_event_for(&stored_event.event, &pk, owner.as_deref())
             })
             .collect()
     } else {
@@ -691,6 +694,17 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
         return;
     }
 
+    if kind_u32 == KIND_SPAWNER_ATTESTATION {
+        if let Err(message) = validate_spawner_attestation_envelope(&event) {
+            reject("invalid");
+            conn.send(RelayMessage::ok(&event_id_hex, false, &message));
+            return;
+        }
+        // Falls through to the generic ephemeral path: channel-less ephemeral
+        // events fan out on the global `#p`-kind index, which already routes
+        // each frame to the tagged counterparty and nobody else.
+    }
+
     // Scope enforcement for ephemeral kinds: require MessagesWrite.
     // Persistent events skip this gate and rely on
     // ingest_event()'s per-kind scope allowlist instead, so a token with
@@ -940,6 +954,55 @@ fn observer_frame_rate_limited(
 /// These frames bypass storage and are routed as global ephemeral events. The
 /// relay gates publication by the existing `agent_owner_pubkey` mapping and
 /// gates subscription in the REQ handler via the cleartext `p` tag.
+/// Validate the envelope of a NIP-AS spawner attestation frame (kind 24201).
+///
+/// These frames carry a NIP-OA auth tag between a spawner and an agent owner
+/// who have no recorded relationship yet — establishing that relationship is
+/// the entire point of the handshake — so this gate cannot reuse the agent/owner
+/// DB check that kind:24200 observer frames rely on. What it can enforce is that
+/// the frame is addressed to exactly one counterparty, is genuinely encrypted,
+/// and is fresh:
+///
+/// - Exactly one `p` tag, so the frame cannot be broadcast to many readers or
+///   published untargeted onto the global ephemeral index.
+/// - NIP-44 v2 ciphertext, so a client bug cannot put a signed auth tag on the
+///   wire in the clear.
+/// - Within a ±5 minute window, matching the observer-frame policy, so a
+///   captured frame cannot be replayed later.
+///
+/// Confidentiality rests on the encryption, not on this routing: a reader who
+/// subscribes with someone else's `p` value receives ciphertext they hold no key
+/// for. Binding a response to its request is the spawner's job, via the nonce.
+fn validate_spawner_attestation_envelope(event: &Event) -> Result<(), String> {
+    let mut p_tags = event.tags.iter().filter_map(|t| {
+        let parts = t.as_slice();
+        (parts.len() >= 2 && parts[0].as_str() == "p").then(|| parts[1].as_str())
+    });
+    let Some(recipient) = p_tags.next() else {
+        return Err("invalid: spawner attestation frame requires exactly one p tag".into());
+    };
+    if p_tags.next().is_some() {
+        return Err("invalid: spawner attestation frame requires exactly one p tag".into());
+    }
+    if recipient.len() != 64 || !recipient.chars().all(|c| c.is_ascii_hexdigit()) {
+        return Err("invalid: spawner attestation p tag must be a 64-character hex pubkey".into());
+    }
+
+    if !content_looks_like_nip44(event.content.as_ref()) {
+        return Err("invalid: spawner attestation content must be NIP-44 v2 ciphertext".into());
+    }
+
+    let now = chrono::Utc::now().timestamp();
+    let event_ts = event.created_at.as_secs() as i64;
+    if (event_ts - now).unsigned_abs() > 300 {
+        return Err(
+            "invalid: spawner attestation timestamp outside ±5 minute freshness window".into(),
+        );
+    }
+
+    Ok(())
+}
+
 async fn handle_agent_observer_event(
     event: Event,
     conn_id: uuid::Uuid,
@@ -1238,6 +1301,98 @@ mod tests {
         );
     }
 
+    /// Build a kind:24201 attestation frame with real NIP-44 ciphertext.
+    fn spawner_attestation_event(
+        sender: &Keys,
+        recipient: &nostr::PublicKey,
+        tags: Vec<Tag>,
+    ) -> nostr::Event {
+        let encrypted = encrypt_observer_payload(
+            sender,
+            recipient,
+            &serde_json::json!({"type": "request", "nonce": "ab"}),
+        )
+        .expect("encrypt attestation payload");
+        EventBuilder::new(
+            Kind::Custom(buzz_core::kind::KIND_SPAWNER_ATTESTATION as u16),
+            encrypted,
+        )
+        .tags(tags)
+        .sign_with_keys(sender)
+        .expect("sign event")
+    }
+
+    #[test]
+    fn spawner_attestation_accepts_a_single_p_tagged_encrypted_frame() {
+        let spawner = Keys::generate();
+        let owner = Keys::generate();
+        let event = spawner_attestation_event(
+            &spawner,
+            &owner.public_key(),
+            vec![Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag")],
+        );
+        assert!(super::validate_spawner_attestation_envelope(&event).is_ok());
+    }
+
+    #[test]
+    fn spawner_attestation_rejects_missing_or_multiple_p_tags() {
+        let spawner = Keys::generate();
+        let owner = Keys::generate();
+        let other = Keys::generate();
+
+        let untargeted = spawner_attestation_event(&spawner, &owner.public_key(), vec![]);
+        assert!(super::validate_spawner_attestation_envelope(&untargeted).is_err());
+
+        // Fan-out to several readers would put one owner's handshake on another
+        // owner's subscription; exactly one counterparty is the whole contract.
+        let broadcast = spawner_attestation_event(
+            &spawner,
+            &owner.public_key(),
+            vec![
+                Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag"),
+                Tag::parse(["p", &other.public_key().to_hex()]).expect("p tag"),
+            ],
+        );
+        assert!(super::validate_spawner_attestation_envelope(&broadcast).is_err());
+    }
+
+    #[test]
+    fn spawner_attestation_rejects_plaintext_content() {
+        let spawner = Keys::generate();
+        let owner = Keys::generate();
+        // A signed auth tag in the clear is exactly what this gate exists to stop.
+        let event = EventBuilder::new(
+            Kind::Custom(buzz_core::kind::KIND_SPAWNER_ATTESTATION as u16),
+            r#"{"type":"response","auth_tag":"[\"auth\",\"owner\",\"\",\"sig\"]"}"#,
+        )
+        .tags([Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag")])
+        .sign_with_keys(&spawner)
+        .expect("sign event");
+        assert!(super::validate_spawner_attestation_envelope(&event).is_err());
+    }
+
+    #[test]
+    fn spawner_attestation_rejects_stale_frames() {
+        let spawner = Keys::generate();
+        let owner = Keys::generate();
+        let encrypted = encrypt_observer_payload(
+            &spawner,
+            &owner.public_key(),
+            &serde_json::json!({"type": "request"}),
+        )
+        .expect("encrypt attestation payload");
+        let stale = nostr::Timestamp::from((chrono::Utc::now().timestamp() - 3600).unsigned_abs());
+        let event = EventBuilder::new(
+            Kind::Custom(buzz_core::kind::KIND_SPAWNER_ATTESTATION as u16),
+            encrypted,
+        )
+        .tags([Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag")])
+        .custom_created_at(stale)
+        .sign_with_keys(&spawner)
+        .expect("sign event");
+        assert!(super::validate_spawner_attestation_envelope(&event).is_err());
+    }
+
     #[test]
     fn agent_observer_route_accepts_agent_to_owner_telemetry() {
         let agent = Keys::generate();
diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs
index ee644d5a9b..25ca9f331a 100644
--- a/crates/buzz-relay/src/handlers/ingest.rs
+++ b/crates/buzz-relay/src/handlers/ingest.rs
@@ -29,6 +29,7 @@ use buzz_core::kind::{
     KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
     KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
     KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
+    KIND_SPAWNER_AGENT_SPEC, KIND_SPAWNER_AGENT_STATUS, KIND_SPAWNER_ANNOUNCEMENT,
     KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
     KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
     KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS,
@@ -214,6 +215,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
         KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
         KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
         | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
+        | KIND_SPAWNER_AGENT_SPEC | KIND_SPAWNER_AGENT_STATUS | KIND_SPAWNER_ANNOUNCEMENT
         | super::push_lease::KIND_PUSH_LEASE => {
             Ok(Scope::UsersWrite)
         }
@@ -423,6 +425,12 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
             // keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them.
             | KIND_TEAM
             | KIND_MANAGED_AGENT
+            // NIP-AS: spawner agent spec (30178) + status (30179): keyed by
+            // (pubkey, kind, d_tag) where d_tag is a spec slug, never a channel.
+            | KIND_SPAWNER_AGENT_SPEC
+            | KIND_SPAWNER_AGENT_STATUS
+            // NIP-AS: spawner announcement (10180) is keyed by (pubkey, kind).
+            | KIND_SPAWNER_ANNOUNCEMENT
             // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
             // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
             | KIND_GIT_REPO_ANNOUNCEMENT
diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs
index 51400452d7..133be88ac3 100644
--- a/crates/buzz-relay/src/handlers/req.rs
+++ b/crates/buzz-relay/src/handlers/req.rs
@@ -7,7 +7,7 @@ use tracing::{debug, warn};
 
 use buzz_core::filter::filters_match;
 use buzz_core::kind::{
-    is_unshared_persona_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC,
+    is_unshared_persona_event_for, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC,
     KIND_DM_VISIBILITY, KIND_PERSONA, P_GATED_KINDS, RESULT_GATED_KINDS,
 };
 use buzz_core::tenant::TenantContext;
@@ -47,7 +47,7 @@ pub async fn handle_req(
     conn: Arc<ConnectionState>,
     state: Arc<AppState>,
 ) {
-    let (conn_id, pubkey_bytes, token_channel_ids) = {
+    let (conn_id, pubkey_bytes, agent_owner_bytes, token_channel_ids) = {
         let auth = conn.auth_state.read().await;
         match &*auth {
             AuthState::Authenticated(ctx) => {
@@ -71,7 +71,12 @@ pub async fn handle_req(
                     return;
                 }
 
-                (conn.conn_id, pk_bytes, ctx.channel_ids.clone())
+                let owner_bytes = ctx
+                    .agent_owner_pubkey
+                    .as_ref()
+                    .map(|owner| owner.to_bytes().to_vec());
+
+                (conn.conn_id, pk_bytes, owner_bytes, ctx.channel_ids.clone())
             }
             _ => {
                 conn.send(RelayMessage::notice(
@@ -224,6 +229,7 @@ pub async fn handle_req(
             token_channel_ids.is_none(),
             &conn.tenant,
             &pubkey_bytes,
+            agent_owner_bytes.as_deref(),
             &conn,
             &state,
             trace_state.as_ref(),
@@ -385,7 +391,11 @@ pub async fn handle_req(
             // Also enforces author-only kinds (30300/30350) and the persona
             // shared-gate (kind:30175 without ["shared","true"]). Single call
             // covers all three gated event classes.
-            if !event_visible_to_reader(&stored.event, &pubkey_bytes) {
+            if !event_visible_to_reader_for(
+                &stored.event,
+                &pubkey_bytes,
+                agent_owner_bytes.as_deref(),
+            ) {
                 continue;
             }
 
@@ -509,6 +519,7 @@ async fn handle_search_req(
     include_global: bool,
     tenant: &TenantContext,
     reader_pubkey_bytes: &[u8],
+    reader_owner_pubkey_bytes: Option<&[u8]>,
     conn: &ConnectionState,
     state: &AppState,
     trace_state: Option<&crate::conformance::AbstractState>,
@@ -702,7 +713,11 @@ async fn handle_search_req(
                     }
                     // Result-level gate: covers author-only, persona shared-gate,
                     // and result-gated kinds in one call.
-                    if !event_visible_to_reader(&stored.event, reader_pubkey_bytes) {
+                    if !event_visible_to_reader_for(
+                        &stored.event,
+                        reader_pubkey_bytes,
+                        reader_owner_pubkey_bytes,
+                    ) {
                         continue;
                     }
                     // Dedup AFTER acceptance — an event that fails filter A's constraints
@@ -1220,10 +1235,29 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes:
 /// (NIP-98 `/query`, `/count`, FTS search) — instead of inlining the three
 /// individual predicates at each site.
 pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool {
+    event_visible_to_reader_for(event, requester_pubkey_bytes, None)
+}
+
+/// NIP-OA-aware variant of [`event_visible_to_reader`].
+///
+/// `requester_owner_pubkey_bytes` is the reader's attesting owner, present when
+/// the connection authenticated with an `auth` tag. It relaxes exactly one gate
+/// — the persona shared-gate — so an owner-attested agent (notably a
+/// `buzz-spawner` daemon resolving the system prompt for an agent it runs) can
+/// read its own owner's unshared personas without the owner having to publish
+/// them `["shared","true"]` to the whole community.
+///
+/// It deliberately does NOT relax the author-only or result-gated checks:
+/// delegation grants persona reads, not blanket impersonation.
+pub(crate) fn event_visible_to_reader_for(
+    event: &nostr::Event,
+    requester_pubkey_bytes: &[u8],
+    requester_owner_pubkey_bytes: Option<&[u8]>,
+) -> bool {
     if is_author_only_event(event, requester_pubkey_bytes) {
         return false;
     }
-    if is_unshared_persona_event(event, requester_pubkey_bytes) {
+    if is_unshared_persona_event_for(event, requester_pubkey_bytes, requester_owner_pubkey_bytes) {
         return false;
     }
     let requester_pubkey_hex = hex::encode(requester_pubkey_bytes);
diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs
index 58a869a995..0f0799f60f 100644
--- a/crates/buzz-relay/src/state.rs
+++ b/crates/buzz-relay/src/state.rs
@@ -54,6 +54,11 @@ struct ConnEntry {
     backpressure_count: Arc<AtomicU8>,
     subscriptions: ConnectionSubscriptions,
     authenticated_pubkey: Arc<std::sync::RwLock<Option<Vec<u8>>>>,
+    /// NIP-OA attesting owner for this connection, when it authenticated with an
+    /// `auth` tag. Mirrors `AuthContext::agent_owner_pubkey`, kept here so the
+    /// synchronous fan-out path can consult it without awaiting the per-socket
+    /// `auth_state` lock.
+    agent_owner_pubkey: Arc<std::sync::RwLock<Option<Vec<u8>>>>,
     grace_limit: u8,
 }
 
@@ -225,6 +230,7 @@ impl ConnectionManager {
                 backpressure_count,
                 subscriptions,
                 authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)),
+                agent_owner_pubkey: Arc::new(std::sync::RwLock::new(None)),
                 grace_limit,
             },
         );
@@ -252,6 +258,26 @@ impl ConnectionManager {
         }
     }
 
+    /// Record the NIP-OA attesting owner for a connection after NIP-42 succeeds.
+    ///
+    /// Pass `None` for a connection that authenticated without an `auth` tag, so
+    /// a re-auth on the same socket clears a previously attested owner rather
+    /// than leaving a stale delegation in place.
+    pub fn set_agent_owner_pubkey(&self, conn_id: Uuid, owner_bytes: Option<Vec<u8>>) {
+        if let Some(entry) = self.connections.get(&conn_id) {
+            if let Ok(mut slot) = entry.agent_owner_pubkey.write() {
+                *slot = owner_bytes;
+            }
+        }
+    }
+
+    /// Return the NIP-OA attesting owner recorded for a connection, if any.
+    pub fn agent_owner_for_conn(&self, conn_id: Uuid) -> Option<Vec<u8>> {
+        self.connections
+            .get(&conn_id)
+            .and_then(|entry| entry.agent_owner_pubkey.read().ok()?.clone())
+    }
+
     /// Return live connection IDs authenticated as `pubkey_bytes` in one community.
     ///
     /// The same Nostr key may be connected to multiple communities at once.
diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs
index 4ee0cd4c88..065d7c7535 100644
--- a/crates/buzz-sdk/src/lib.rs
+++ b/crates/buzz-sdk/src/lib.rs
@@ -15,6 +15,7 @@
 pub mod builders;
 pub mod mentions;
 pub mod nip_oa;
+pub mod spawner;
 
 pub use builders::*;
 
diff --git a/crates/buzz-sdk/src/spawner.rs b/crates/buzz-sdk/src/spawner.rs
new file mode 100644
index 0000000000..ced1aafbbd
--- /dev/null
+++ b/crates/buzz-sdk/src/spawner.rs
@@ -0,0 +1,1018 @@
+//! NIP-AS — Agent Spawner.
+//!
+//! Typed content bodies and event builders for server-hosted agents: an owner
+//! publishes a *spec* describing an agent they want running, a `buzz-spawner`
+//! daemon reconciles it into a container and publishes *status* back, and the
+//! two exchange an ephemeral *attestation* handshake so the daemon can obtain a
+//! NIP-OA auth tag for a key it minted itself.
+//!
+//! # Why the handshake exists
+//!
+//! The agent's secret key is generated on the spawner host and never leaves it.
+//! But a NIP-OA auth tag is
+//! `Schnorr(SHA256("nostr:agent-auth:" || agent_pubkey || ":" || conditions), owner_secret)`
+//! (see [`crate::nip_oa`]) — it binds one specific agent pubkey and requires the
+//! *owner's* secret key. The spawner cannot self-attest, and the owner cannot
+//! pre-authorize a pubkey that does not exist yet. Hence two rounds:
+//!
+//! ```text
+//! owner   ──kind:30178 spec──────────────────────────►  spawner
+//! spawner ──kind:24201 AttestationRequest (pubkey+nonce)►  owner
+//! owner   ──kind:24201 AttestationResponse (auth tag)──►  spawner
+//! spawner ──kind:30179 status: running────────────────►  owner
+//! ```
+//!
+//! # Security: specs are world-readable
+//!
+//! [`SpawnerAgentSpec`] is an explicit opt-IN projection, exactly like the
+//! desktop's kind:30177 managed-agent events. It MUST NEVER carry a secret key,
+//! an auth tag, env vars, or provider credentials. The type is the structural
+//! guard: it physically cannot represent those fields, so a malicious inbound
+//! spec cannot smuggle them onto the spawn path. Add a field here only after
+//! asking whether it would be safe printed on a billboard.
+//!
+//! Only the ephemeral [`KIND_SPAWNER_ATTESTATION`] frames carry sensitive
+//! material (the auth tag), and those are NIP-44 encrypted and `#p`-gated.
+//!
+//! # Status events are self-addressing
+//!
+//! Status is a NIP-33 parameterized replaceable event addressed by
+//! `(pubkey, kind, d_tag)`. Because the author is part of the address, a status
+//! event published by an impostor lands at *their* address, not the spawner's —
+//! it cannot overwrite the real one. Clients therefore read status at the
+//! address of a spawner pubkey they already trust, and the relay needs no
+//! special author gate for this kind.
+
+use buzz_core::kind::{
+    KIND_SPAWNER_AGENT_SPEC, KIND_SPAWNER_AGENT_STATUS, KIND_SPAWNER_ANNOUNCEMENT,
+    KIND_SPAWNER_ATTESTATION,
+};
+use buzz_core::observer::content_looks_like_nip44;
+use nostr::{EventBuilder, Kind, Tag};
+use serde::{Deserialize, Serialize};
+
+use crate::SdkError;
+
+/// Tag name carrying the pubkey of the spawner a spec is addressed to.
+pub const SPAWNER_TAG: &str = "spawner";
+
+/// Maximum byte length of a spec slug (`d` tag).
+pub const MAX_SPEC_SLUG_LEN: usize = 64;
+
+/// Maximum byte length of a serialized spec or status content body.
+pub const MAX_SPAWNER_CONTENT_BYTES: usize = 32 * 1024;
+
+/// Maximum number of allowlisted author pubkeys on a spec.
+pub const MAX_RESPOND_TO_ALLOWLIST: usize = 256;
+
+/// Byte length of the attestation handshake nonce.
+pub const ATTESTATION_NONCE_BYTES: usize = 32;
+
+// ---------------------------------------------------------------------------
+// Announcement (kind 10180)
+// ---------------------------------------------------------------------------
+
+/// A spawner advertising itself so owners can find it — content of kind:10180.
+///
+/// Every field is self-reported and unverifiable. A client may show these to
+/// help a user choose, but must not treat any of them as a security property:
+/// authorization comes from the per-agent attestation the owner signs, never
+/// from an announcement.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SpawnerAnnouncement {
+    /// Human-readable name, e.g. "prod-vps" or "gpu-box".
+    pub name: String,
+    /// Optional longer description shown in a picker.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub description: Option<String>,
+    /// Agent runtime image this spawner runs.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub agent_image: Option<String>,
+    /// ACP agent binary this spawner runs, e.g. `claude-agent-acp`.
+    ///
+    /// Display-only. A client shows it so "prod-vps — Claude Code" is legible
+    /// in a picker, but it is self-reported and confers nothing: the host alone
+    /// decides what executes there, and no spec can influence it.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub runtime: Option<String>,
+    /// How many agents it will run at once.
+    pub max_agents: u32,
+    /// How many it is running now, so a full spawner can be shown as such.
+    pub agents_running: u32,
+    /// Per-agent CPU ceiling, thousandths of a core.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub max_cpu_millis: Option<u32>,
+    /// Per-agent memory ceiling, mebibytes.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub max_memory_mib: Option<u32>,
+}
+
+impl SpawnerAnnouncement {
+    /// Validate the announcement's own invariants.
+    pub fn validate(&self) -> Result<(), SdkError> {
+        if self.name.trim().is_empty() {
+            return Err(SdkError::InvalidInput(
+                "spawner name must not be empty".into(),
+            ));
+        }
+        if self.name.len() > 128 {
+            return Err(SdkError::InvalidInput(format!(
+                "spawner name exceeds 128 bytes (got {})",
+                self.name.len()
+            )));
+        }
+        if let Some(description) = &self.description {
+            if description.len() > 512 {
+                return Err(SdkError::InvalidInput(format!(
+                    "spawner description exceeds 512 bytes (got {})",
+                    description.len()
+                )));
+            }
+        }
+        Ok(())
+    }
+
+    /// Whether the spawner reports itself at capacity.
+    ///
+    /// Advisory only — a client should still let the user try, because these
+    /// numbers are a snapshot the spawner chose to publish.
+    pub fn is_full(&self) -> bool {
+        self.agents_running >= self.max_agents
+    }
+}
+
+/// Build a spawner announcement event (kind 10180).
+pub fn build_spawner_announcement(
+    announcement: &SpawnerAnnouncement,
+) -> Result<EventBuilder, SdkError> {
+    announcement.validate()?;
+    let content = serde_json::to_string(announcement)
+        .map_err(|e| SdkError::InvalidInput(format!("failed to serialize announcement: {e}")))?;
+    check_spawner_content(&content)?;
+    Ok(EventBuilder::new(
+        Kind::Custom(KIND_SPAWNER_ANNOUNCEMENT as u16),
+        content,
+    ))
+}
+
+/// Parse a kind:10180 event's content into a [`SpawnerAnnouncement`].
+pub fn announcement_from_event(event: &nostr::Event) -> Result<SpawnerAnnouncement, SdkError> {
+    let announcement: SpawnerAnnouncement = serde_json::from_str(event.content.as_ref())
+        .map_err(|e| SdkError::InvalidInput(format!("failed to parse announcement: {e}")))?;
+    announcement.validate()?;
+    Ok(announcement)
+}
+
+// ---------------------------------------------------------------------------
+// Spec (kind 30178)
+// ---------------------------------------------------------------------------
+
+/// Inbound author gate for a spawned agent — mirrors the desktop's `RespondTo`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "kebab-case")]
+pub enum RespondTo {
+    /// Respond to anyone who can address the agent.
+    #[default]
+    Anyone,
+    /// Respond only to the attesting owner.
+    OwnerOnly,
+    /// Respond only to pubkeys on `respond_to_allowlist`.
+    Allowlist,
+}
+
+/// Container resource limits for a spawned agent.
+///
+/// Both fields are optional; the spawner applies its own configured defaults
+/// when they are absent, and clamps anything above its configured ceiling. A
+/// spec asking for more than the host allows is clamped, not rejected — the
+/// owner should get a smaller agent, not a broken one.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct ResourceRequest {
+    /// CPU allocation in thousandths of a core (1000 = one full core).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cpu_millis: Option<u32>,
+    /// Memory limit in mebibytes.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub memory_mib: Option<u32>,
+}
+
+/// Desired state for a server-hosted agent — the content body of kind:30178.
+///
+/// See the module docs for the opt-IN projection contract. `system_prompt`,
+/// `model`, and `provider` follow the same slimming rule as kind:30177: when
+/// `persona_id` is set they are resolved through the referenced kind:30175
+/// persona and omitted here, so a prompt lives in exactly one place.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SpawnerAgentSpec {
+    /// Display name for the agent.
+    pub name: String,
+    /// Existing agent identity this spec relocates, rather than minting a new one.
+    ///
+    /// # Why relocation, not creation
+    ///
+    /// An agent's pubkey *is* its continuity: channel membership, its kind:0
+    /// profile, its kind:30177 coordinate (whose `d` tag is this pubkey), DMs,
+    /// turn metrics, the relay's `users.agent_owner_pubkey` row, and — most
+    /// destructively — its NIP-AE memory, whose d-tags derive from
+    /// `conversation_key(agent_seckey, owner_pubkey)`. A new key changes every
+    /// d-tag *and* leaves the old ciphertext undecryptable, so the memory is
+    /// gone for good.
+    ///
+    /// So moving an existing agent to a spawner names it here and delivers its
+    /// secret key over the encrypted handshake, exactly as a provider deploy
+    /// already ships `private_key_nsec` to a remote runner
+    /// (`desktop/src-tauri/src/commands/agents_deploy.rs`). Only the public key
+    /// appears here; the secret never touches a stored event.
+    ///
+    /// `None` means "mint a fresh identity", which is right for an agent that
+    /// has no local existence to preserve.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub agent_pubkey: Option<String>,
+    /// Persona (kind:30175) this agent is an instance of, if any.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub persona_id: Option<String>,
+    /// Inline system prompt. Set only for definition-less specs.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub system_prompt: Option<String>,
+    /// Model id. Set only for definition-less specs.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub model: Option<String>,
+    /// Inference provider id. Set only for definition-less specs.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub provider: Option<String>,
+    /// Number of concurrent ACP sessions the harness should hold.
+    #[serde(default = "default_parallelism")]
+    pub parallelism: u32,
+    /// Inbound author gate mode.
+    #[serde(default)]
+    pub respond_to: RespondTo,
+    /// Allowlisted author pubkeys when `respond_to == Allowlist`. Public keys,
+    /// not secrets.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub respond_to_allowlist: Vec<String>,
+    /// Requested container resources.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub resources: Option<ResourceRequest>,
+    /// When false the spawner tears the container down but keeps the spec, so
+    /// the agent keeps its identity and can be resumed. Deleting the spec
+    /// (NIP-09) is the permanent teardown signal.
+    #[serde(default = "default_enabled")]
+    pub enabled: bool,
+}
+
+fn default_parallelism() -> u32 {
+    1
+}
+
+fn default_enabled() -> bool {
+    true
+}
+
+impl SpawnerAgentSpec {
+    /// Validate the spec's own invariants.
+    ///
+    /// Called by [`build_spawner_agent_spec`] on the write path and by the
+    /// spawner on the read path — an inbound spec is untrusted input even
+    /// though it is signed, because a signature proves authorship, not sanity.
+    pub fn validate(&self) -> Result<(), SdkError> {
+        if self.name.trim().is_empty() {
+            return Err(SdkError::InvalidInput("spec name must not be empty".into()));
+        }
+        if self.name.len() > 128 {
+            return Err(SdkError::InvalidInput(format!(
+                "spec name exceeds 128 bytes (got {})",
+                self.name.len()
+            )));
+        }
+        // Deliberately no "must carry a prompt" rule. Prompt material normally
+        // arrives over the encrypted kind:24201 handshake precisely so it does
+        // NOT appear here — a spec is world-readable, and requiring a prompt on
+        // it would force every server agent's instructions to be public.
+        if self.parallelism == 0 || self.parallelism > 16 {
+            return Err(SdkError::InvalidInput(format!(
+                "parallelism must be in 1..=16 (got {})",
+                self.parallelism
+            )));
+        }
+        if self.respond_to_allowlist.len() > MAX_RESPOND_TO_ALLOWLIST {
+            return Err(SdkError::InvalidInput(format!(
+                "respond_to_allowlist exceeds {MAX_RESPOND_TO_ALLOWLIST} entries (got {})",
+                self.respond_to_allowlist.len()
+            )));
+        }
+        for pk in &self.respond_to_allowlist {
+            check_pubkey_hex(pk, "respond_to_allowlist entry")?;
+        }
+        if let Some(agent_pubkey) = &self.agent_pubkey {
+            check_pubkey_hex(agent_pubkey, "agent_pubkey")?;
+        }
+        if self.respond_to == RespondTo::Allowlist && self.respond_to_allowlist.is_empty() {
+            return Err(SdkError::InvalidInput(
+                "respond_to=allowlist requires a non-empty respond_to_allowlist".into(),
+            ));
+        }
+        Ok(())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Status (kind 30179)
+// ---------------------------------------------------------------------------
+
+/// Reconciliation phase reported by the spawner.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SpawnPhase {
+    /// Keys minted; waiting for the owner to return a signed auth tag.
+    PendingAttestation,
+    /// Attested; container being created.
+    Starting,
+    /// Container running.
+    Running,
+    /// Reconciliation failed. `error` carries a human-readable reason.
+    Failed,
+    /// Intentionally not running — spec has `enabled: false`.
+    Stopped,
+}
+
+/// Actual state of a spawned agent — the content body of kind:30179.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SpawnerAgentStatus {
+    /// Current reconciliation phase.
+    pub phase: SpawnPhase,
+    /// The minted agent pubkey, present from `PendingAttestation` onward.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub agent_pubkey: Option<String>,
+    /// Hash of the spec content this status reflects, so a client can tell
+    /// whether the spawner has caught up with an edit.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub spec_hash: Option<String>,
+    /// Human-readable failure reason. Set iff `phase == Failed`.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub error: Option<String>,
+    /// Consecutive failed start attempts, for surfacing backoff in a UI.
+    #[serde(default, skip_serializing_if = "is_zero")]
+    pub restart_count: u32,
+}
+
+fn is_zero(n: &u32) -> bool {
+    *n == 0
+}
+
+impl SpawnerAgentStatus {
+    /// Validate the status's own invariants.
+    pub fn validate(&self) -> Result<(), SdkError> {
+        if let Some(pk) = &self.agent_pubkey {
+            check_pubkey_hex(pk, "agent_pubkey")?;
+        }
+        if self.phase == SpawnPhase::Failed && self.error.is_none() {
+            return Err(SdkError::InvalidInput(
+                "phase=failed requires an error message".into(),
+            ));
+        }
+        if let Some(err) = &self.error {
+            if err.len() > 2048 {
+                return Err(SdkError::InvalidInput(format!(
+                    "status error exceeds 2048 bytes (got {})",
+                    err.len()
+                )));
+            }
+        }
+        Ok(())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Attestation handshake (kind 24201)
+// ---------------------------------------------------------------------------
+
+/// Prompt material delivered to a spawner over the encrypted handshake.
+///
+/// # Why this travels here rather than on the spec
+///
+/// A kind:30178 spec is world-readable, so inlining a system prompt there would
+/// publish it to the whole community — as would marking the persona
+/// `["shared","true"]`. The attestation channel is already NIP-44 encrypted
+/// owner-to-spawner, and the owner can obviously read their own persona, so
+/// delivering the prompt here keeps it private with no relay involvement at all.
+///
+/// The cost is that a prompt edit does not reach a running spawner by itself;
+/// the owner sends an [`AttestationFrame::PromptUpdate`] to push a new one.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PromptMaterial {
+    /// The agent's system prompt.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub system_prompt: Option<String>,
+    /// Team-level instructions appended after the system prompt.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub team_instructions: Option<String>,
+    /// Model id.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub model: Option<String>,
+    /// Inference provider id.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub provider: Option<String>,
+}
+
+impl PromptMaterial {
+    /// Whether this carries anything worth storing.
+    pub fn is_empty(&self) -> bool {
+        self.system_prompt.is_none()
+            && self.team_instructions.is_none()
+            && self.model.is_none()
+            && self.provider.is_none()
+    }
+}
+
+/// The plaintext payload of a [`KIND_SPAWNER_ATTESTATION`] frame, before NIP-44
+/// encryption.
+///
+/// `nonce` binds the two rounds together: the spawner will only accept a
+/// response whose nonce matches the request it is still waiting on, so a
+/// replayed or crossed response for a different agent is rejected rather than
+/// silently applied.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum AttestationFrame {
+    /// Spawner → owner: "I minted this pubkey for your spec; please attest it."
+    Request {
+        /// The `d` tag of the spec this agent belongs to.
+        spec_slug: String,
+        /// The freshly minted agent pubkey, hex.
+        agent_pubkey: String,
+        /// NIP-OA conditions string the owner should sign over. Usually empty.
+        #[serde(default)]
+        conditions: String,
+        /// Hex-encoded random nonce, [`ATTESTATION_NONCE_BYTES`] bytes.
+        nonce: String,
+    },
+    /// Owner → spawner: the signed auth tag.
+    Response {
+        /// Echoed spec slug.
+        spec_slug: String,
+        /// Echoed agent pubkey — must match the pending request.
+        agent_pubkey: String,
+        /// Echoed nonce — must match the pending request.
+        nonce: String,
+        /// The NIP-OA `auth` tag as a JSON array string, in the form
+        /// `["auth", "<owner-pubkey>", "<conditions>", "<sig>"]`. Verify with
+        /// [`crate::nip_oa::verify_auth_tag`] before use.
+        auth_tag: String,
+        /// Prompt material for this agent, when the spawner cannot read the
+        /// referenced persona itself. Optional so a shared-persona deployment
+        /// need not duplicate it.
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        prompt: Option<PromptMaterial>,
+        /// Secret key of an existing agent being relocated to this spawner.
+        ///
+        /// Present only when the spec named an `agent_pubkey`. The enclosing
+        /// frame is NIP-44 encrypted to the spawner and the kind is ephemeral,
+        /// so the key is never stored by the relay — but it does transit it, and
+        /// that is the deliberate cost of keeping one identity across hosts.
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        private_key_nsec: Option<String>,
+    },
+    /// Owner → spawner: replacement prompt material for an existing agent.
+    ///
+    /// Sent after a persona edit. Carries no nonce because it opens no round —
+    /// the spawner accepts it only for an agent it already holds an attestation
+    /// for, from that agent's own owner, so there is nothing to bind it to.
+    PromptUpdate {
+        /// The `d` tag of the spec this agent belongs to.
+        spec_slug: String,
+        /// The agent being updated.
+        agent_pubkey: String,
+        /// The new prompt material.
+        prompt: PromptMaterial,
+    },
+    /// Owner → spawner: refusal. Lets a client decline explicitly instead of
+    /// leaving the spawner waiting on a timeout.
+    Reject {
+        /// Echoed spec slug.
+        spec_slug: String,
+        /// Echoed agent pubkey.
+        agent_pubkey: String,
+        /// Echoed nonce.
+        nonce: String,
+        /// Human-readable reason, surfaced in the spawner's status event.
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        reason: Option<String>,
+    },
+}
+
+impl AttestationFrame {
+    /// The agent pubkey this frame concerns, regardless of variant.
+    pub fn agent_pubkey(&self) -> &str {
+        match self {
+            Self::Request { agent_pubkey, .. }
+            | Self::Response { agent_pubkey, .. }
+            | Self::Reject { agent_pubkey, .. }
+            | Self::PromptUpdate { agent_pubkey, .. } => agent_pubkey,
+        }
+    }
+
+    /// The nonce binding this frame to a handshake round.
+    ///
+    /// Empty for [`Self::PromptUpdate`], which opens no round.
+    pub fn nonce(&self) -> &str {
+        match self {
+            Self::Request { nonce, .. }
+            | Self::Response { nonce, .. }
+            | Self::Reject { nonce, .. } => nonce,
+            Self::PromptUpdate { .. } => "",
+        }
+    }
+
+    /// The spec slug this frame concerns.
+    pub fn spec_slug(&self) -> &str {
+        match self {
+            Self::Request { spec_slug, .. }
+            | Self::Response { spec_slug, .. }
+            | Self::Reject { spec_slug, .. }
+            | Self::PromptUpdate { spec_slug, .. } => spec_slug,
+        }
+    }
+
+    /// Validate structural invariants shared by every variant.
+    pub fn validate(&self) -> Result<(), SdkError> {
+        check_spec_slug(self.spec_slug())?;
+        check_pubkey_hex(self.agent_pubkey(), "agent_pubkey")?;
+        // A prompt update opens no handshake round, so it carries no nonce.
+        if matches!(self, Self::PromptUpdate { .. }) {
+            return Ok(());
+        }
+        let nonce = self.nonce();
+        if nonce.len() != ATTESTATION_NONCE_BYTES * 2
+            || !nonce.chars().all(|c| c.is_ascii_hexdigit())
+        {
+            return Err(SdkError::InvalidInput(format!(
+                "nonce must be {} hex characters",
+                ATTESTATION_NONCE_BYTES * 2
+            )));
+        }
+        Ok(())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Builders
+// ---------------------------------------------------------------------------
+
+/// Build a spawner agent spec event (kind 30178).
+///
+/// `slug` becomes the `d` tag and is the stable handle for this agent across
+/// edits — it is chosen by the client, not derived from the agent pubkey, which
+/// does not exist until the spawner mints it.
+///
+/// `spawner_pubkey` becomes a `spawner` tag so a host running more than one
+/// daemon, or a client watching several, can filter without parsing content.
+pub fn build_spawner_agent_spec(
+    slug: &str,
+    spawner_pubkey: &str,
+    spec: &SpawnerAgentSpec,
+) -> Result<EventBuilder, SdkError> {
+    let slug = check_spec_slug(slug)?;
+    let spawner_pubkey = check_pubkey_hex(spawner_pubkey, "spawner_pubkey")?;
+    spec.validate()?;
+
+    let content = serde_json::to_string(spec)
+        .map_err(|e| SdkError::InvalidInput(format!("failed to serialize spec: {e}")))?;
+    check_spawner_content(&content)?;
+
+    let tags = vec![
+        parse_tag(&["d", &slug])?,
+        parse_tag(&[SPAWNER_TAG, &spawner_pubkey])?,
+        parse_tag(&["p", &spawner_pubkey])?,
+    ];
+    Ok(EventBuilder::new(Kind::Custom(KIND_SPAWNER_AGENT_SPEC as u16), content).tags(tags))
+}
+
+/// Build a spawner agent status event (kind 30179).
+///
+/// `owner_pubkey` becomes a `p` tag so the owner's client can subscribe to
+/// status for its own agents without knowing every slug in advance.
+pub fn build_spawner_agent_status(
+    slug: &str,
+    owner_pubkey: &str,
+    status: &SpawnerAgentStatus,
+) -> Result<EventBuilder, SdkError> {
+    let slug = check_spec_slug(slug)?;
+    let owner_pubkey = check_pubkey_hex(owner_pubkey, "owner_pubkey")?;
+    status.validate()?;
+
+    let content = serde_json::to_string(status)
+        .map_err(|e| SdkError::InvalidInput(format!("failed to serialize status: {e}")))?;
+    check_spawner_content(&content)?;
+
+    let mut tags = vec![parse_tag(&["d", &slug])?, parse_tag(&["p", &owner_pubkey])?];
+    // Surface the agent pubkey as a tag too, so a client can resolve
+    // slug → agent identity from the tag index without reading content.
+    if let Some(agent_pubkey) = &status.agent_pubkey {
+        tags.push(parse_tag(&["agent", agent_pubkey])?);
+    }
+    Ok(EventBuilder::new(Kind::Custom(KIND_SPAWNER_AGENT_STATUS as u16), content).tags(tags))
+}
+
+/// Build an ephemeral attestation handshake frame (kind 24201).
+///
+/// `recipient_pubkey` is the cleartext `p` tag the relay uses to route the
+/// frame to exactly one counterparty. `encrypted_content` must be NIP-44 v2
+/// ciphertext of a serialized [`AttestationFrame`] — this builder deliberately
+/// takes ciphertext rather than the frame itself, so no code path can construct
+/// a plaintext auth tag on the wire by mistake.
+pub fn build_spawner_attestation(
+    recipient_pubkey: &str,
+    encrypted_content: &str,
+) -> Result<EventBuilder, SdkError> {
+    let recipient_pubkey = check_pubkey_hex(recipient_pubkey, "recipient_pubkey")?;
+    if !content_looks_like_nip44(encrypted_content) {
+        return Err(SdkError::InvalidInput(
+            "attestation frame content must be NIP-44 v2 ciphertext".into(),
+        ));
+    }
+    check_spawner_content(encrypted_content)?;
+
+    Ok(EventBuilder::new(
+        Kind::Custom(KIND_SPAWNER_ATTESTATION as u16),
+        encrypted_content,
+    )
+    .tags(vec![parse_tag(&["p", &recipient_pubkey])?]))
+}
+
+// ---------------------------------------------------------------------------
+// Parsers
+// ---------------------------------------------------------------------------
+
+/// Parse a kind:30178 event's content into a [`SpawnerAgentSpec`].
+///
+/// # Security
+///
+/// Returns the projection type, which physically cannot represent a secret key,
+/// auth tag, env vars, or provider config — unknown keys in a malicious event
+/// are dropped at deserialization rather than filtered afterward. The spec is
+/// validated here as well; a signature proves authorship, not sanity.
+pub fn spec_from_event(event: &nostr::Event) -> Result<SpawnerAgentSpec, SdkError> {
+    let spec: SpawnerAgentSpec = serde_json::from_str(event.content.as_ref())
+        .map_err(|e| SdkError::InvalidInput(format!("failed to parse spec content: {e}")))?;
+    spec.validate()?;
+    Ok(spec)
+}
+
+/// Parse a kind:30179 event's content into a [`SpawnerAgentStatus`].
+pub fn status_from_event(event: &nostr::Event) -> Result<SpawnerAgentStatus, SdkError> {
+    let status: SpawnerAgentStatus = serde_json::from_str(event.content.as_ref())
+        .map_err(|e| SdkError::InvalidInput(format!("failed to parse status content: {e}")))?;
+    status.validate()?;
+    Ok(status)
+}
+
+/// Read the `d` tag (spec slug) from a spawner event.
+pub fn spec_slug_from_event(event: &nostr::Event) -> Option<String> {
+    single_tag_value(event, "d")
+}
+
+/// Read the `spawner` tag (target spawner pubkey) from a spec event.
+pub fn spawner_pubkey_from_event(event: &nostr::Event) -> Option<String> {
+    single_tag_value(event, SPAWNER_TAG)
+}
+
+fn single_tag_value(event: &nostr::Event, name: &str) -> Option<String> {
+    event.tags.iter().find_map(|t| {
+        let parts = t.as_slice();
+        (parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].to_string())
+    })
+}
+
+// ---------------------------------------------------------------------------
+// Local validation helpers
+// ---------------------------------------------------------------------------
+
+fn parse_tag(parts: &[&str]) -> Result<Tag, SdkError> {
+    Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string()))
+}
+
+fn check_pubkey_hex(s: &str, field: &str) -> Result<String, SdkError> {
+    if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
+        return Err(SdkError::InvalidInput(format!(
+            "{field} must be a 64-character hex pubkey"
+        )));
+    }
+    Ok(s.to_ascii_lowercase())
+}
+
+/// Validate a spec slug.
+///
+/// Slugs appear in container names, volume names, and log paths on the spawner
+/// host, so the character set is deliberately narrow: lowercase alphanumerics,
+/// hyphens, and underscores. That rules out path traversal, shell
+/// metacharacters, and Docker name-rule violations in one pass, at the point
+/// where the value enters the system rather than at each use site.
+pub fn check_spec_slug(slug: &str) -> Result<String, SdkError> {
+    if slug.is_empty() {
+        return Err(SdkError::InvalidInput("spec slug must not be empty".into()));
+    }
+    if slug.len() > MAX_SPEC_SLUG_LEN {
+        return Err(SdkError::InvalidInput(format!(
+            "spec slug exceeds {MAX_SPEC_SLUG_LEN} bytes (got {})",
+            slug.len()
+        )));
+    }
+    if !slug
+        .chars()
+        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
+    {
+        return Err(SdkError::InvalidInput(
+            "spec slug may only contain lowercase ASCII letters, digits, hyphens, and underscores"
+                .into(),
+        ));
+    }
+    if slug.starts_with('-') || slug.starts_with('_') {
+        return Err(SdkError::InvalidInput(
+            "spec slug must start with a letter or digit".into(),
+        ));
+    }
+    Ok(slug.to_string())
+}
+
+fn check_spawner_content(content: &str) -> Result<(), SdkError> {
+    let got = content.len();
+    if got > MAX_SPAWNER_CONTENT_BYTES {
+        return Err(SdkError::ContentTooLarge {
+            max: MAX_SPAWNER_CONTENT_BYTES,
+            got,
+        });
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nostr::Keys;
+
+    fn sample_spec() -> SpawnerAgentSpec {
+        SpawnerAgentSpec {
+            name: "Fizz".into(),
+            agent_pubkey: None,
+            persona_id: Some("builtin:fizz".into()),
+            system_prompt: None,
+            model: None,
+            provider: None,
+            parallelism: 1,
+            respond_to: RespondTo::Anyone,
+            respond_to_allowlist: Vec::new(),
+            resources: Some(ResourceRequest {
+                cpu_millis: Some(1000),
+                memory_mib: Some(2048),
+            }),
+            enabled: true,
+        }
+    }
+
+    #[test]
+    fn spec_round_trips_through_an_event() {
+        let keys = Keys::generate();
+        let spawner = Keys::generate();
+        let spec = sample_spec();
+        let event = build_spawner_agent_spec("fizz-prod", &spawner.public_key().to_hex(), &spec)
+            .unwrap()
+            .sign_with_keys(&keys)
+            .unwrap();
+
+        assert_eq!(spec_slug_from_event(&event).as_deref(), Some("fizz-prod"));
+        assert_eq!(
+            spawner_pubkey_from_event(&event),
+            Some(spawner.public_key().to_hex())
+        );
+        assert_eq!(spec_from_event(&event).unwrap(), spec);
+    }
+
+    #[test]
+    fn spec_rejects_secret_bearing_keys_by_dropping_them() {
+        // A malicious spec that tries to smuggle an nsec and an auth tag. The
+        // projection type has no such fields, so they are dropped at parse.
+        let json = r#"{
+            "name": "Evil",
+            "persona_id": "builtin:fizz",
+            "private_key_nsec": "nsec1deadbeef",
+            "auth_tag": "[\"auth\",\"..\"]",
+            "env_vars": {"ANTHROPIC_API_KEY": "sk-leak"}
+        }"#;
+        let spec: SpawnerAgentSpec = serde_json::from_str(json).unwrap();
+        assert_eq!(spec.name, "Evil");
+        // Re-serializing cannot reproduce the smuggled fields.
+        let round_tripped = serde_json::to_string(&spec).unwrap();
+        assert!(!round_tripped.contains("nsec"));
+        assert!(!round_tripped.contains("auth_tag"));
+        assert!(!round_tripped.contains("sk-leak"));
+    }
+
+    #[test]
+    fn spec_may_name_an_existing_identity_to_relocate() {
+        let agent = Keys::generate();
+        let mut spec = sample_spec();
+        spec.agent_pubkey = Some(agent.public_key().to_hex());
+        assert!(spec.validate().is_ok());
+
+        // Only the public key ever appears on a spec — it is world-readable.
+        let json = serde_json::to_string(&spec).unwrap();
+        assert!(json.contains(&agent.public_key().to_hex()));
+        assert!(!json.contains("nsec"));
+
+        spec.agent_pubkey = Some("not-a-pubkey".into());
+        assert!(spec.validate().is_err());
+    }
+
+    #[test]
+    fn spec_need_not_carry_a_prompt() {
+        // Prompt material rides the encrypted handshake so it never becomes
+        // public. A spec with neither persona_id nor system_prompt is the
+        // normal, private case — not an error.
+        let mut spec = sample_spec();
+        spec.persona_id = None;
+        spec.system_prompt = None;
+        assert!(spec.validate().is_ok());
+    }
+
+    #[test]
+    fn spec_rejects_empty_allowlist_when_gated_on_it() {
+        let mut spec = sample_spec();
+        spec.respond_to = RespondTo::Allowlist;
+        assert!(spec.validate().is_err());
+        spec.respond_to_allowlist = vec![Keys::generate().public_key().to_hex()];
+        assert!(spec.validate().is_ok());
+    }
+
+    #[test]
+    fn slug_rejects_traversal_and_metacharacters() {
+        for bad in [
+            "../etc/passwd",
+            "fizz prod",
+            "fizz;rm -rf /",
+            "Fizz",
+            "-leading",
+            "",
+        ] {
+            assert!(
+                check_spec_slug(bad).is_err(),
+                "slug {bad:?} should be rejected"
+            );
+        }
+        assert!(check_spec_slug("fizz-prod_2").is_ok());
+    }
+
+    #[test]
+    fn status_failed_requires_an_error() {
+        let mut status = SpawnerAgentStatus {
+            phase: SpawnPhase::Failed,
+            agent_pubkey: None,
+            spec_hash: None,
+            error: None,
+            restart_count: 3,
+        };
+        assert!(status.validate().is_err());
+        status.error = Some("image pull failed".into());
+        assert!(status.validate().is_ok());
+    }
+
+    #[test]
+    fn status_round_trips_and_tags_the_agent() {
+        let keys = Keys::generate();
+        let owner = Keys::generate();
+        let agent = Keys::generate();
+        let status = SpawnerAgentStatus {
+            phase: SpawnPhase::Running,
+            agent_pubkey: Some(agent.public_key().to_hex()),
+            spec_hash: Some("abc123".into()),
+            error: None,
+            restart_count: 0,
+        };
+        let event = build_spawner_agent_status("fizz-prod", &owner.public_key().to_hex(), &status)
+            .unwrap()
+            .sign_with_keys(&keys)
+            .unwrap();
+
+        assert_eq!(status_from_event(&event).unwrap(), status);
+        assert_eq!(
+            single_tag_value(&event, "agent"),
+            Some(agent.public_key().to_hex())
+        );
+        assert_eq!(
+            single_tag_value(&event, "p"),
+            Some(owner.public_key().to_hex())
+        );
+    }
+
+    #[test]
+    fn announcement_round_trips_through_an_event() {
+        let keys = Keys::generate();
+        let announcement = SpawnerAnnouncement {
+            name: "prod-vps".into(),
+            description: Some("Hetzner CX42, Frankfurt".into()),
+            agent_image: Some("ghcr.io/block/buzz-acp:main".into()),
+            runtime: Some("claude-agent-acp".into()),
+            max_agents: 16,
+            agents_running: 3,
+            max_cpu_millis: Some(4000),
+            max_memory_mib: Some(8192),
+        };
+        let event = build_spawner_announcement(&announcement)
+            .unwrap()
+            .sign_with_keys(&keys)
+            .unwrap();
+        assert_eq!(announcement_from_event(&event).unwrap(), announcement);
+    }
+
+    #[test]
+    fn announcement_reports_capacity() {
+        let mut a = SpawnerAnnouncement {
+            name: "vps".into(),
+            description: None,
+            agent_image: None,
+            runtime: None,
+            max_agents: 2,
+            agents_running: 1,
+            max_cpu_millis: None,
+            max_memory_mib: None,
+        };
+        assert!(!a.is_full());
+        a.agents_running = 2;
+        assert!(a.is_full());
+        // Over-capacity can happen after a limit is lowered; still full.
+        a.agents_running = 5;
+        assert!(a.is_full());
+    }
+
+    #[test]
+    fn announcement_rejects_an_empty_name() {
+        // A nameless spawner would render as a blank row in a picker, which is
+        // worse than not listing it.
+        let a = SpawnerAnnouncement {
+            name: "   ".into(),
+            description: None,
+            agent_image: None,
+            runtime: None,
+            max_agents: 1,
+            agents_running: 0,
+            max_cpu_millis: None,
+            max_memory_mib: None,
+        };
+        assert!(a.validate().is_err());
+    }
+
+    #[test]
+    fn announcement_cannot_carry_secrets() {
+        // Announcements are world-readable by design. The projection type must
+        // drop anything a malicious or careless spawner adds.
+        let json = r#"{"name":"evil","max_agents":1,"agents_running":0,
+            "private_key_nsec":"nsec1leak","env_vars":{"K":"sk-leak"}}"#;
+        let a: SpawnerAnnouncement = serde_json::from_str(json).unwrap();
+        let round_tripped = serde_json::to_string(&a).unwrap();
+        assert!(!round_tripped.contains("nsec"));
+        assert!(!round_tripped.contains("sk-leak"));
+    }
+
+    #[test]
+    fn attestation_requires_nip44_ciphertext() {
+        let recipient = Keys::generate().public_key().to_hex();
+        assert!(build_spawner_attestation(&recipient, "plaintext auth tag").is_err());
+    }
+
+    #[test]
+    fn attestation_frame_validates_nonce_width() {
+        let agent = Keys::generate().public_key().to_hex();
+        let mut frame = AttestationFrame::Request {
+            spec_slug: "fizz-prod".into(),
+            agent_pubkey: agent.clone(),
+            conditions: String::new(),
+            nonce: "ab".repeat(ATTESTATION_NONCE_BYTES),
+        };
+        assert!(frame.validate().is_ok());
+
+        frame = AttestationFrame::Request {
+            spec_slug: "fizz-prod".into(),
+            agent_pubkey: agent,
+            conditions: String::new(),
+            nonce: "tooshort".into(),
+        };
+        assert!(frame.validate().is_err());
+    }
+
+    #[test]
+    fn attestation_frame_json_is_tagged_by_type() {
+        let frame = AttestationFrame::Response {
+            spec_slug: "fizz-prod".into(),
+            agent_pubkey: Keys::generate().public_key().to_hex(),
+            nonce: "ab".repeat(ATTESTATION_NONCE_BYTES),
+            auth_tag: r#"["auth","owner","","sig"]"#.into(),
+            prompt: None,
+            private_key_nsec: None,
+        };
+        let json = serde_json::to_string(&frame).unwrap();
+        assert!(json.contains(r#""type":"response""#));
+        assert_eq!(
+            serde_json::from_str::<AttestationFrame>(&json).unwrap(),
+            frame
+        );
+    }
+}
diff --git a/crates/buzz-spawner/Cargo.toml b/crates/buzz-spawner/Cargo.toml
new file mode 100644
index 0000000000..7c8ac044a6
--- /dev/null
+++ b/crates/buzz-spawner/Cargo.toml
@@ -0,0 +1,36 @@
+[package]
+name = "buzz-spawner"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "Reconciles Nostr agent specs into Docker-isolated buzz-acp containers"
+
+[[bin]]
+name = "buzz-spawner"
+path = "src/main.rs"
+
+[dependencies]
+buzz-core = { workspace = true }
+buzz-sdk = { workspace = true }
+buzz-ws-client = { workspace = true }
+
+nostr = { workspace = true }
+tokio = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+anyhow = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+hex = { workspace = true }
+sha2 = { workspace = true }
+rand = { workspace = true }
+futures-util = { workspace = true }
+chrono = { workspace = true }
+async-trait = "0.1"
+bollard = "0.21"
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["test-util"] }
diff --git a/crates/buzz-spawner/examples/owner_sim.rs b/crates/buzz-spawner/examples/owner_sim.rs
new file mode 100644
index 0000000000..24b6913f89
--- /dev/null
+++ b/crates/buzz-spawner/examples/owner_sim.rs
@@ -0,0 +1,322 @@
+//! Owner-side simulator for exercising a spawner end to end without the
+//! desktop app.
+//!
+//! Stands in for the React attestation prompt: publishes an agent spec, answers
+//! the spawner's NIP-OA attestation request, and prints status as it arrives.
+//! It signs every request automatically — the real client asks a human first,
+//! which is exactly the difference this simulator exists to skip.
+//!
+//! ```sh
+//! cargo run -p buzz-spawner --example owner_sim -- \
+//!   --relay ws://127.0.0.1:3000 \
+//!   --spawner <spawner-pubkey-hex> \
+//!   --owner-nsec <nsec> \
+//!   --slug fizz-prod
+//! ```
+
+use std::{collections::HashMap, time::Duration};
+
+use anyhow::{bail, Context, Result};
+use buzz_core::kind::{KIND_SPAWNER_AGENT_STATUS, KIND_SPAWNER_ATTESTATION};
+use buzz_sdk::nip_oa;
+use buzz_sdk::spawner::{
+    build_spawner_agent_spec, build_spawner_attestation, status_from_event, AttestationFrame,
+    RespondTo, SpawnerAgentSpec,
+};
+use buzz_ws_client::{connection::NostrWsConnection, message::RelayMessage};
+use nostr::{Keys, PublicKey};
+use serde_json::json;
+
+#[tokio::main]
+async fn main() -> Result<()> {
+    let args = Args::parse()?;
+
+    let keys = match &args.owner_nsec {
+        Some(nsec) => Keys::parse(nsec).context("invalid --owner-nsec")?,
+        None => Keys::generate(),
+    };
+    let spawner = PublicKey::parse(&args.spawner).context("invalid --spawner")?;
+
+    println!("owner   : {}", keys.public_key().to_hex());
+    println!("spawner : {}", spawner.to_hex());
+    println!("slug    : {}", args.slug);
+    println!();
+
+    let mut conn = NostrWsConnection::connect_authenticated(&args.relay, &keys, None)
+        .await
+        .with_context(|| format!("failed to authenticate to {}", args.relay))?;
+    println!("✓ authenticated to {}", args.relay);
+
+    // Watch for the handshake and for status, before publishing the spec — a
+    // spawner can react faster than a second round trip.
+    conn.send_raw(&json!([
+        "REQ", "attest",
+        { "kinds": [KIND_SPAWNER_ATTESTATION], "#p": [keys.public_key().to_hex()] }
+    ]))
+    .await?;
+    conn.send_raw(&json!([
+        "REQ", "status",
+        { "kinds": [KIND_SPAWNER_AGENT_STATUS], "#p": [keys.public_key().to_hex()] }
+    ]))
+    .await?;
+
+    // Optionally publish an unshared persona and point the spec at it, to
+    // exercise the relay's persona read-gate from the spawner's side.
+    if let Some(persona_id) = &args.persona {
+        let content = serde_json::json!({
+            "display_name": args.name,
+            "system_prompt": "You are a persona-backed test agent.",
+        })
+        .to_string();
+        let mut tags = vec![nostr::Tag::parse(["d", persona_id.as_str()])?];
+        if args.share_persona {
+            tags.push(nostr::Tag::parse(["shared", "true"])?);
+        }
+        let event = nostr::EventBuilder::new(
+            nostr::Kind::Custom(buzz_core::kind::KIND_PERSONA as u16),
+            content,
+        )
+        .tags(tags)
+        .sign_with_keys(&keys)?;
+        let ok = conn.send_event(event).await?;
+        if !ok.accepted {
+            bail!("relay rejected the persona: {}", ok.message);
+        }
+        println!(
+            "✓ published persona {persona_id} (shared={})",
+            args.share_persona
+        );
+    }
+
+    if args.delete {
+        // An emptied replacement is the tombstone convention for a
+        // parameterized-replaceable kind; a kind:5 deletion would leave the
+        // spawner nothing to fan out. Built directly because the SDK builder
+        // validates the spec body, which a tombstone deliberately lacks.
+        let event = nostr::EventBuilder::new(
+            nostr::Kind::Custom(buzz_core::kind::KIND_SPAWNER_AGENT_SPEC as u16),
+            "",
+        )
+        .tags(vec![
+            nostr::Tag::parse(["d", args.slug.as_str()])?,
+            nostr::Tag::parse(["spawner", spawner.to_hex().as_str()])?,
+            nostr::Tag::parse(["p", spawner.to_hex().as_str()])?,
+        ])
+        .sign_with_keys(&keys)?;
+        let ok = conn.send_event(event).await?;
+        println!(
+            "✓ published tombstone for {} (accepted={})",
+            args.slug, ok.accepted
+        );
+    } else {
+        let event = build_spawner_agent_spec(&args.slug, &spawner.to_hex(), &spec_for(&args))?
+            .sign_with_keys(&keys)?;
+        let ok = conn.send_event(event).await?;
+        if !ok.accepted {
+            bail!("relay rejected the spec: {}", ok.message);
+        }
+        println!("✓ published spec {} → {}", args.slug, spawner.to_hex());
+    }
+
+    println!("\nwatching (Ctrl-C to stop)…\n");
+
+    let mut seen_status: HashMap<String, String> = HashMap::new();
+    loop {
+        let msg = match conn.next_event(Duration::from_secs(30)).await {
+            Ok(msg) => msg,
+            Err(buzz_ws_client::error::WsClientError::Timeout) => continue,
+            Err(e) => return Err(e.into()),
+        };
+
+        let RelayMessage::Event { event, .. } = msg else {
+            continue;
+        };
+        let kind = event.kind.as_u16() as u32;
+
+        if kind == KIND_SPAWNER_ATTESTATION {
+            // Skip our own outbound frames echoed back off the ephemeral stream.
+            if event.pubkey == keys.public_key() {
+                continue;
+            }
+            match handle_attestation(&mut conn, &keys, &event).await {
+                Ok(Some(agent)) => println!("✓ attested agent {agent}"),
+                Ok(None) => {}
+                Err(e) => eprintln!("✗ attestation failed: {e:#}"),
+            }
+            continue;
+        }
+
+        if kind == KIND_SPAWNER_AGENT_STATUS {
+            let Ok(status) = status_from_event(&event) else {
+                continue;
+            };
+            let line = format!(
+                "{:?}{}{}",
+                status.phase,
+                status
+                    .agent_pubkey
+                    .as_deref()
+                    .map(|a| format!(" agent={}", &a[..12]))
+                    .unwrap_or_default(),
+                status
+                    .error
+                    .as_deref()
+                    .map(|e| format!(" error={e}"))
+                    .unwrap_or_default(),
+            );
+            // Status is replaceable; only print genuine transitions.
+            if seen_status.get(&args.slug) != Some(&line) {
+                println!("  status: {line}");
+                seen_status.insert(args.slug.clone(), line);
+            }
+        }
+    }
+}
+
+/// Answer a spawner's attestation request. Returns the attested agent pubkey.
+async fn handle_attestation(
+    conn: &mut NostrWsConnection,
+    keys: &Keys,
+    event: &nostr::Event,
+) -> Result<Option<String>> {
+    let plaintext =
+        nostr::nips::nip44::decrypt(keys.secret_key(), &event.pubkey, event.content.as_str())
+            .context("decrypt")?;
+    let frame: AttestationFrame = serde_json::from_str(&plaintext).context("parse frame")?;
+    frame.validate().context("validate frame")?;
+
+    let AttestationFrame::Request {
+        spec_slug,
+        agent_pubkey,
+        conditions,
+        nonce,
+    } = frame
+    else {
+        // Our own response echoed back, or a reject. Nothing to do.
+        return Ok(None);
+    };
+
+    println!(
+        "→ attestation request for agent {} (slug {spec_slug})",
+        &agent_pubkey[..12]
+    );
+
+    let agent = PublicKey::parse(&agent_pubkey).context("invalid agent pubkey in request")?;
+    let auth_tag = nip_oa::compute_auth_tag(keys, &agent, &conditions).context("compute tag")?;
+
+    let response = AttestationFrame::Response {
+        spec_slug,
+        agent_pubkey: agent_pubkey.clone(),
+        nonce,
+        auth_tag,
+        // Deliver the prompt over the encrypted channel, which is the whole
+        // point of the handshake carrying it: the spec stays free of it and the
+        // agent's instructions never become public.
+        // Only set when relocating an existing agent; this simulator mints.
+        private_key_nsec: None,
+        prompt: Some(buzz_sdk::spawner::PromptMaterial {
+            system_prompt: Some("You are a test agent running on a Buzz spawner.".into()),
+            team_instructions: None,
+            model: None,
+            provider: None,
+        }),
+    };
+    let ciphertext = nostr::nips::nip44::encrypt(
+        keys.secret_key(),
+        &event.pubkey,
+        serde_json::to_string(&response)?,
+        nostr::nips::nip44::Version::V2,
+    )
+    .context("encrypt response")?;
+
+    let out =
+        build_spawner_attestation(&event.pubkey.to_hex(), &ciphertext)?.sign_with_keys(keys)?;
+    let ok = conn.send_event(out).await?;
+    if !ok.accepted {
+        bail!("relay rejected the attestation response: {}", ok.message);
+    }
+    Ok(Some(agent_pubkey))
+}
+
+fn spec_for(args: &Args) -> SpawnerAgentSpec {
+    SpawnerAgentSpec {
+        name: args.name.clone(),
+        // This simulator always mints a fresh identity rather than relocating
+        // an existing agent.
+        agent_pubkey: None,
+        // Inline prompt rather than a persona id: this simulator has no persona
+        // store, and a definition-less spec is the path that does not depend on
+        // the relay's persona read-delegation.
+        persona_id: args.persona.clone(),
+        // A persona-backed spec omits the prompt, matching how kind:30177 slims
+        // against kind:30175 — the spawner must resolve it from the relay.
+        system_prompt: if args.persona.is_some() {
+            None
+        } else {
+            Some("You are a test agent running on a Buzz spawner.".into())
+        },
+        model: None,
+        provider: None,
+        parallelism: 1,
+        respond_to: RespondTo::Anyone,
+        respond_to_allowlist: Vec::new(),
+        resources: None,
+        enabled: !args.disabled,
+    }
+}
+
+struct Args {
+    relay: String,
+    spawner: String,
+    owner_nsec: Option<String>,
+    slug: String,
+    name: String,
+    disabled: bool,
+    delete: bool,
+    persona: Option<String>,
+    share_persona: bool,
+}
+
+impl Args {
+    fn parse() -> Result<Self> {
+        let mut relay = "ws://127.0.0.1:3000".to_string();
+        let mut spawner = None;
+        let mut owner_nsec = std::env::var("OWNER_NSEC").ok();
+        let mut slug = "test-agent".to_string();
+        let mut name = "Test Agent".to_string();
+        let mut disabled = false;
+        let mut delete = false;
+        let mut persona = None;
+        let mut share_persona = false;
+
+        let mut args = std::env::args().skip(1);
+        while let Some(arg) = args.next() {
+            match arg.as_str() {
+                "--relay" => relay = args.next().context("--relay needs a value")?,
+                "--spawner" => spawner = Some(args.next().context("--spawner needs a value")?),
+                "--owner-nsec" => {
+                    owner_nsec = Some(args.next().context("--owner-nsec needs a value")?)
+                }
+                "--slug" => slug = args.next().context("--slug needs a value")?,
+                "--name" => name = args.next().context("--name needs a value")?,
+                "--disabled" => disabled = true,
+                "--delete" => delete = true,
+                "--persona" => persona = Some(args.next().context("--persona needs a value")?),
+                "--share-persona" => share_persona = true,
+                other => bail!("unknown argument {other}"),
+            }
+        }
+
+        Ok(Self {
+            relay,
+            spawner: spawner.context("--spawner <pubkey-hex> is required")?,
+            owner_nsec,
+            slug,
+            name,
+            disabled,
+            delete,
+            persona,
+            share_persona,
+        })
+    }
+}
diff --git a/crates/buzz-spawner/src/attestation.rs b/crates/buzz-spawner/src/attestation.rs
new file mode 100644
index 0000000000..258637b699
--- /dev/null
+++ b/crates/buzz-spawner/src/attestation.rs
@@ -0,0 +1,396 @@
+//! The two-round NIP-OA attestation handshake, spawner side.
+//!
+//! The agent's secret key is minted here and never leaves the host, so the
+//! spawner cannot produce its own owner attestation — a NIP-OA auth tag is
+//! signed with the *owner's* secret key over the agent's pubkey. The owner must
+//! therefore sign a key they did not generate, which they can only do after the
+//! spawner tells them what it is.
+//!
+//! This module is deliberately free of I/O: [`evaluate_response`] is a pure
+//! function over a stored record and an inbound frame, so every rejection path
+//! below is directly testable.
+
+use anyhow::{bail, Context, Result};
+use buzz_sdk::nip_oa;
+use buzz_sdk::spawner::{AttestationFrame, PromptMaterial, ATTESTATION_NONCE_BYTES};
+use nostr::{Keys, PublicKey};
+
+use crate::store::AgentRecord;
+
+/// Generate a fresh handshake nonce.
+pub fn new_nonce() -> String {
+    let bytes: [u8; ATTESTATION_NONCE_BYTES] = rand::random();
+    hex::encode(bytes)
+}
+
+/// What the reconciler should do with an inbound attestation frame.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ResponseOutcome {
+    /// The owner attested. Store `auth_tag` and start the container.
+    Accept {
+        /// Verified NIP-OA auth tag, as a JSON array string.
+        auth_tag: String,
+        /// Prompt material delivered alongside the tag, if any.
+        prompt: Option<PromptMaterial>,
+        /// Secret key of a relocated agent, when the spec named one.
+        private_key_nsec: Option<String>,
+    },
+    /// The owner explicitly declined.
+    Rejected {
+        /// Reason to surface in the status event.
+        reason: String,
+    },
+}
+
+/// Decide what an inbound attestation frame means for `record`.
+///
+/// `sender` is the *verified* author of the kind:24201 event, taken from the
+/// signed envelope rather than from the frame body — a frame cannot name its own
+/// author into being.
+///
+/// Every check here exists because skipping it is exploitable:
+///
+/// - **Sender is the record's owner.** Otherwise any pubkey could attest an
+///   agent to *itself*, and the relay would then admit that agent under an
+///   attacker's membership.
+/// - **A round is actually in flight.** Without this, a frame replayed after the
+///   handshake completes could swap a live agent's auth tag.
+/// - **Nonce matches.** Binds the response to this specific request, so a frame
+///   captured from an earlier round for the same agent cannot be replayed.
+/// - **Agent pubkey matches.** Stops a response for agent A being applied to
+///   agent B when an owner has several pending at once.
+/// - **The tag actually verifies, and verifies to this owner.** `verify_auth_tag`
+///   recovers the owner pubkey from the signature; a tag that verifies to
+///   somebody else is a delegation the owner never made.
+pub fn evaluate_response(
+    record: &AgentRecord,
+    sender: &PublicKey,
+    frame: &AttestationFrame,
+) -> Result<ResponseOutcome> {
+    frame.validate().context("malformed attestation frame")?;
+
+    if sender.to_hex() != record.owner_pubkey {
+        bail!(
+            "attestation from {} does not own agent {}",
+            sender.to_hex(),
+            record.agent_pubkey
+        );
+    }
+
+    let Some(pending) = record.pending_nonce.as_deref() else {
+        bail!(
+            "no attestation round in flight for agent {}",
+            record.agent_pubkey
+        );
+    };
+    if frame.nonce() != pending {
+        bail!("attestation nonce does not match the pending request");
+    }
+    if frame.agent_pubkey() != record.agent_pubkey {
+        bail!("attestation agent pubkey does not match the pending request");
+    }
+    if frame.spec_slug() != record.slug {
+        bail!("attestation spec slug does not match the pending request");
+    }
+
+    match frame {
+        AttestationFrame::Request { .. } => {
+            bail!("received an attestation request where a response was expected")
+        }
+        AttestationFrame::Reject { reason, .. } => Ok(ResponseOutcome::Rejected {
+            reason: reason
+                .clone()
+                .unwrap_or_else(|| "owner declined the attestation".into()),
+        }),
+        AttestationFrame::PromptUpdate { .. } => {
+            // Handled separately: it opens no round, so it has no nonce to
+            // match and must not be evaluated as a handshake response.
+            bail!("prompt updates are not attestation responses")
+        }
+        AttestationFrame::Response {
+            auth_tag,
+            prompt,
+            private_key_nsec,
+            ..
+        } => {
+            let agent_pubkey = PublicKey::parse(&record.agent_pubkey)
+                .context("stored agent pubkey is not a valid public key")?;
+            let recovered = nip_oa::verify_auth_tag(auth_tag, &agent_pubkey)
+                .context("auth tag failed NIP-OA verification")?;
+            if recovered != *sender {
+                bail!(
+                    "auth tag verifies to {} but was sent by {}",
+                    recovered.to_hex(),
+                    sender.to_hex()
+                );
+            }
+            // A delivered key must actually be the identity we asked about,
+            // or the spawner would run a different agent than the owner named
+            // and than the auth tag attests.
+            if let Some(nsec) = private_key_nsec {
+                let delivered =
+                    Keys::parse(nsec).context("delivered private key is not a valid secret key")?;
+                if delivered.public_key() != agent_pubkey {
+                    bail!(
+                        "delivered key is for {}, but this handshake is about {}",
+                        delivered.public_key().to_hex(),
+                        record.agent_pubkey
+                    );
+                }
+            }
+
+            Ok(ResponseOutcome::Accept {
+                auth_tag: auth_tag.clone(),
+                prompt: prompt.clone(),
+                private_key_nsec: private_key_nsec.clone(),
+            })
+        }
+    }
+}
+
+/// Whether a pending attestation request has aged past `timeout_secs`.
+pub fn is_attestation_expired(record: &AgentRecord, now: i64, timeout_secs: i64) -> bool {
+    match record.attestation_sent_at {
+        Some(sent_at) => now.saturating_sub(sent_at) > timeout_secs,
+        // A record with a pending nonce but no timestamp predates this field;
+        // treat it as expired so it is re-requested rather than stuck forever.
+        None => record.pending_nonce.is_some(),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nostr::Keys;
+
+    struct Fixture {
+        owner: Keys,
+        agent: Keys,
+        record: AgentRecord,
+        nonce: String,
+    }
+
+    fn fixture() -> Fixture {
+        let owner = Keys::generate();
+        let agent = Keys::generate();
+        let nonce = new_nonce();
+        let record = AgentRecord {
+            slug: "fizz-prod".into(),
+            owner_pubkey: owner.public_key().to_hex(),
+            agent_pubkey: agent.public_key().to_hex(),
+            private_key_nsec: "nsec1placeholder".into(),
+            auth_tag: None,
+            pending_nonce: Some(nonce.clone()),
+            attestation_sent_at: Some(1_000),
+            spec_hash: None,
+            prompt: None,
+            restart_count: 0,
+            last_failure_at: None,
+        };
+        Fixture {
+            owner,
+            agent,
+            record,
+            nonce,
+        }
+    }
+
+    fn response(f: &Fixture, signer: &Keys, nonce: &str) -> AttestationFrame {
+        let auth_tag = nip_oa::compute_auth_tag(signer, &f.agent.public_key(), "").unwrap();
+        AttestationFrame::Response {
+            spec_slug: f.record.slug.clone(),
+            agent_pubkey: f.agent.public_key().to_hex(),
+            nonce: nonce.to_string(),
+            auth_tag,
+            prompt: None,
+            private_key_nsec: None,
+        }
+    }
+
+    #[test]
+    fn accepts_a_well_formed_response_from_the_owner() {
+        let f = fixture();
+        let frame = response(&f, &f.owner, &f.nonce);
+        let outcome = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap();
+        assert!(matches!(outcome, ResponseOutcome::Accept { .. }));
+    }
+
+    #[test]
+    fn rejects_attestation_from_a_non_owner() {
+        // The core attack: a stranger attesting somebody else's agent to
+        // themselves, which would admit that agent under the stranger's
+        // relay membership.
+        let f = fixture();
+        let attacker = Keys::generate();
+        let frame = response(&f, &attacker, &f.nonce);
+        let err = evaluate_response(&f.record, &attacker.public_key(), &frame).unwrap_err();
+        assert!(err.to_string().contains("does not own agent"));
+    }
+
+    #[test]
+    fn rejects_a_tag_that_verifies_to_someone_other_than_the_sender() {
+        // Owner sends the frame, but the tag inside was signed by a third
+        // party. Checking only the sender would let this through.
+        let f = fixture();
+        let third_party = Keys::generate();
+        let frame = response(&f, &third_party, &f.nonce);
+        let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err();
+        assert!(err.to_string().contains("verifies to"));
+    }
+
+    #[test]
+    fn accepts_a_relocated_agents_own_key() {
+        // Moving an existing agent to a spawner: the owner delivers the key
+        // whose pubkey the handshake is already about.
+        let f = fixture();
+        let nsec = {
+            use nostr::nips::nip19::ToBech32;
+            f.agent.secret_key().to_bech32().unwrap()
+        };
+        let auth_tag = nip_oa::compute_auth_tag(&f.owner, &f.agent.public_key(), "").unwrap();
+        let frame = AttestationFrame::Response {
+            spec_slug: f.record.slug.clone(),
+            agent_pubkey: f.agent.public_key().to_hex(),
+            nonce: f.nonce.clone(),
+            auth_tag,
+            prompt: None,
+            private_key_nsec: Some(nsec.clone()),
+        };
+        let outcome = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap();
+        assert_eq!(
+            outcome,
+            ResponseOutcome::Accept {
+                auth_tag: match &frame {
+                    AttestationFrame::Response { auth_tag, .. } => auth_tag.clone(),
+                    _ => unreachable!(),
+                },
+                prompt: None,
+                private_key_nsec: Some(nsec),
+            }
+        );
+    }
+
+    #[test]
+    fn rejects_a_key_for_a_different_agent() {
+        // Without this the spawner would run an identity the owner never named
+        // and the auth tag does not attest — a confused-deputy handover.
+        let f = fixture();
+        let other = Keys::generate();
+        let nsec = {
+            use nostr::nips::nip19::ToBech32;
+            other.secret_key().to_bech32().unwrap()
+        };
+        let auth_tag = nip_oa::compute_auth_tag(&f.owner, &f.agent.public_key(), "").unwrap();
+        let frame = AttestationFrame::Response {
+            spec_slug: f.record.slug.clone(),
+            agent_pubkey: f.agent.public_key().to_hex(),
+            nonce: f.nonce.clone(),
+            auth_tag,
+            prompt: None,
+            private_key_nsec: Some(nsec),
+        };
+        let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err();
+        assert!(err.to_string().contains("delivered key is for"));
+    }
+
+    #[test]
+    fn rejects_a_malformed_delivered_key() {
+        let f = fixture();
+        let auth_tag = nip_oa::compute_auth_tag(&f.owner, &f.agent.public_key(), "").unwrap();
+        let frame = AttestationFrame::Response {
+            spec_slug: f.record.slug.clone(),
+            agent_pubkey: f.agent.public_key().to_hex(),
+            nonce: f.nonce.clone(),
+            auth_tag,
+            prompt: None,
+            private_key_nsec: Some("not-an-nsec".into()),
+        };
+        assert!(evaluate_response(&f.record, &f.owner.public_key(), &frame).is_err());
+    }
+
+    #[test]
+    fn rejects_a_replayed_nonce() {
+        let f = fixture();
+        let frame = response(&f, &f.owner, &new_nonce());
+        let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err();
+        assert!(err.to_string().contains("nonce does not match"));
+    }
+
+    #[test]
+    fn rejects_a_frame_when_no_round_is_in_flight() {
+        // Guards a completed agent against a late replay swapping its auth tag.
+        let mut f = fixture();
+        let frame = response(&f, &f.owner, &f.nonce);
+        f.record.pending_nonce = None;
+        let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err();
+        assert!(err.to_string().contains("no attestation round in flight"));
+    }
+
+    #[test]
+    fn rejects_a_response_aimed_at_a_different_agent() {
+        let f = fixture();
+        let other_agent = Keys::generate();
+        let auth_tag = nip_oa::compute_auth_tag(&f.owner, &other_agent.public_key(), "").unwrap();
+        let frame = AttestationFrame::Response {
+            spec_slug: f.record.slug.clone(),
+            agent_pubkey: other_agent.public_key().to_hex(),
+            nonce: f.nonce.clone(),
+            auth_tag,
+            prompt: None,
+            private_key_nsec: None,
+        };
+        let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err();
+        assert!(err.to_string().contains("agent pubkey does not match"));
+    }
+
+    #[test]
+    fn rejects_a_garbage_auth_tag() {
+        let f = fixture();
+        let frame = AttestationFrame::Response {
+            spec_slug: f.record.slug.clone(),
+            agent_pubkey: f.agent.public_key().to_hex(),
+            nonce: f.nonce.clone(),
+            auth_tag: r#"["auth","not","a","tag"]"#.into(),
+            prompt: None,
+            private_key_nsec: None,
+        };
+        assert!(evaluate_response(&f.record, &f.owner.public_key(), &frame).is_err());
+    }
+
+    #[test]
+    fn surfaces_an_explicit_rejection() {
+        let f = fixture();
+        let frame = AttestationFrame::Reject {
+            spec_slug: f.record.slug.clone(),
+            agent_pubkey: f.agent.public_key().to_hex(),
+            nonce: f.nonce.clone(),
+            reason: Some("unknown spawner".into()),
+        };
+        let outcome = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap();
+        assert_eq!(
+            outcome,
+            ResponseOutcome::Rejected {
+                reason: "unknown spawner".into()
+            }
+        );
+    }
+
+    #[test]
+    fn expiry_needs_both_a_pending_round_and_elapsed_time() {
+        let mut f = fixture();
+        assert!(!is_attestation_expired(&f.record, 1_100, 600));
+        assert!(is_attestation_expired(&f.record, 2_000, 600));
+
+        f.record.pending_nonce = None;
+        f.record.attestation_sent_at = None;
+        assert!(!is_attestation_expired(&f.record, 9_999, 600));
+    }
+
+    #[test]
+    fn nonces_are_full_width_and_distinct() {
+        let a = new_nonce();
+        let b = new_nonce();
+        assert_eq!(a.len(), ATTESTATION_NONCE_BYTES * 2);
+        assert_ne!(a, b);
+    }
+}
diff --git a/crates/buzz-spawner/src/config.rs b/crates/buzz-spawner/src/config.rs
new file mode 100644
index 0000000000..b2891fee7a
--- /dev/null
+++ b/crates/buzz-spawner/src/config.rs
@@ -0,0 +1,230 @@
+//! Environment-driven configuration for the spawner daemon.
+
+use std::{path::PathBuf, time::Duration};
+
+use anyhow::{bail, Context, Result};
+use nostr::Keys;
+
+/// Default agent runtime image. Overridden with `BUZZ_SPAWNER_AGENT_IMAGE`.
+pub const DEFAULT_AGENT_IMAGE: &str = "ghcr.io/block/buzz-acp:main";
+
+/// Default per-agent CPU allocation, in thousandths of a core.
+pub const DEFAULT_CPU_MILLIS: u32 = 1000;
+
+/// Default per-agent memory limit, in mebibytes.
+pub const DEFAULT_MEMORY_MIB: u32 = 2048;
+
+/// Docker label carrying the agent pubkey a container belongs to.
+pub const AGENT_LABEL: &str = "com.buzz.agent";
+
+/// Docker label carrying the spec slug a container reconciles.
+pub const SLUG_LABEL: &str = "com.buzz.spec-slug";
+
+/// Docker label carrying the spawner pubkey that owns a container. Two spawners
+/// sharing one Docker host must not reap each other's containers.
+pub const SPAWNER_LABEL: &str = "com.buzz.spawner";
+
+/// Runtime configuration.
+pub struct Config {
+    /// The spawner's own Nostr identity. Used to authenticate to the relay, to
+    /// receive attestation frames, and to author status events.
+    pub keys: Keys,
+    /// Relay WebSocket URL the spawner itself connects to. In a compose
+    /// deployment this is the internal address (`ws://relay:3000`).
+    pub relay_url: String,
+    /// Relay WebSocket URL handed to agent containers.
+    ///
+    /// Deliberately separate from [`Self::relay_url`]. Agent containers are not
+    /// attached to the relay's internal compose network — an agent runs
+    /// arbitrary shell tools, and joining that network would put Postgres,
+    /// Redis, and MinIO one `nc` away. So agents reach the relay by its public
+    /// address instead, the same way any other client does.
+    pub agent_relay_url: String,
+    /// Directory holding the state file and per-agent secrets. Must be on a
+    /// persistent volume — losing it orphans every running agent's identity.
+    pub state_dir: PathBuf,
+    /// Container image running the `buzz-acp` harness.
+    pub agent_image: String,
+    /// Ceiling applied to a spec's requested CPU. Specs above it are clamped.
+    pub max_cpu_millis: u32,
+    /// Ceiling applied to a spec's requested memory. Specs above it are clamped.
+    pub max_memory_mib: u32,
+    /// Maximum number of agents this spawner will run at once.
+    pub max_agents: usize,
+    /// How long to wait for an owner to answer an attestation request before
+    /// giving up and reporting failure.
+    pub attestation_timeout: Duration,
+    /// How often to reconcile even when no event has arrived, so a container
+    /// that died out-of-band is noticed.
+    pub reconcile_interval: Duration,
+    /// ACP agent binary the harness spawns inside each container.
+    ///
+    /// Operator-only, deliberately. This is a code-execution surface: the
+    /// desktop lists `BUZZ_ACP_AGENT_COMMAND` among its reserved env keys for
+    /// exactly that reason. It must never be settable from a kind:30178 spec,
+    /// which is owner-authored and world-readable — a spec that could name the
+    /// binary would let anyone who can publish one choose what runs on the host.
+    ///
+    /// `None` leaves the image's own default (`buzz-agent`, API-key based). Set
+    /// `claude-agent-acp` to run agents on a Claude subscription.
+    pub agent_command: Option<String>,
+    /// Comma-separated args for [`Self::agent_command`], same rationale.
+    pub agent_args: Option<String>,
+    /// Display name advertised in this spawner's announcement.
+    ///
+    /// Defaults to the hostname, because "prod-vps" is a far better thing to
+    /// choose from in a picker than 64 hex characters.
+    pub name: String,
+    /// Longer description shown alongside the name.
+    pub description: Option<String>,
+    /// Provider used when a spec does not name one.
+    ///
+    /// The `buzz-agent` harness refuses to start without `BUZZ_AGENT_PROVIDER`,
+    /// and a spec is allowed to omit it. The desktop resolves the same gap from
+    /// its global agent settings (`resolve_deploy_model_provider`); a spawner
+    /// has no such settings, so the operator supplies the host default here.
+    pub default_provider: Option<String>,
+    /// Model used when a spec does not name one.
+    pub default_model: Option<String>,
+    /// Environment passed through to every agent container — LLM credentials
+    /// live here, never in an event. Collected from `BUZZ_SPAWNER_AGENT_ENV`.
+    pub agent_env: Vec<(String, String)>,
+}
+
+impl Config {
+    /// Load configuration from the process environment.
+    pub fn from_env() -> Result<Self> {
+        let nsec = require_env("BUZZ_SPAWNER_NSEC")?;
+        let keys = Keys::parse(nsec.trim())
+            .context("BUZZ_SPAWNER_NSEC is not a valid nsec or hex secret key")?;
+
+        let relay_url = require_env("BUZZ_SPAWNER_RELAY_URL")?;
+        check_ws_url("BUZZ_SPAWNER_RELAY_URL", &relay_url)?;
+
+        // Defaulting to the spawner's own relay URL is correct for a single-host
+        // setup where both addresses are the same, and wrong for compose, where
+        // `ws://relay:3000` does not resolve from the Docker bridge. The compose
+        // bundle sets it explicitly; this default keeps a bare `cargo run`
+        // working.
+        let agent_relay_url =
+            std::env::var("BUZZ_SPAWNER_AGENT_RELAY_URL").unwrap_or_else(|_| relay_url.clone());
+        check_ws_url("BUZZ_SPAWNER_AGENT_RELAY_URL", &agent_relay_url)?;
+
+        let state_dir = PathBuf::from(
+            std::env::var("BUZZ_SPAWNER_STATE_DIR")
+                .unwrap_or_else(|_| "/var/lib/buzz-spawner".into()),
+        );
+
+        Ok(Self {
+            keys,
+            relay_url,
+            agent_relay_url,
+            state_dir,
+            agent_image: std::env::var("BUZZ_SPAWNER_AGENT_IMAGE")
+                .unwrap_or_else(|_| DEFAULT_AGENT_IMAGE.into()),
+            max_cpu_millis: parse_env("BUZZ_SPAWNER_MAX_CPU_MILLIS", 4000)?,
+            max_memory_mib: parse_env("BUZZ_SPAWNER_MAX_MEMORY_MIB", 8192)?,
+            max_agents: parse_env("BUZZ_SPAWNER_MAX_AGENTS", 16)?,
+            attestation_timeout: Duration::from_secs(parse_env(
+                "BUZZ_SPAWNER_ATTESTATION_TIMEOUT_SECS",
+                600,
+            )?),
+            reconcile_interval: Duration::from_secs(parse_env(
+                "BUZZ_SPAWNER_RECONCILE_INTERVAL_SECS",
+                60,
+            )?),
+            agent_command: non_empty_env("BUZZ_SPAWNER_AGENT_COMMAND"),
+            agent_args: non_empty_env("BUZZ_SPAWNER_AGENT_ARGS"),
+            name: non_empty_env("BUZZ_SPAWNER_NAME").unwrap_or_else(default_name),
+            description: non_empty_env("BUZZ_SPAWNER_DESCRIPTION"),
+            default_provider: non_empty_env("BUZZ_SPAWNER_DEFAULT_PROVIDER"),
+            default_model: non_empty_env("BUZZ_SPAWNER_DEFAULT_MODEL"),
+            agent_env: parse_agent_env(
+                &std::env::var("BUZZ_SPAWNER_AGENT_ENV").unwrap_or_default(),
+            )?,
+        })
+    }
+}
+
+fn check_ws_url(key: &str, url: &str) -> Result<()> {
+    if !url.starts_with("ws://") && !url.starts_with("wss://") {
+        bail!("{key} must start with ws:// or wss://");
+    }
+    Ok(())
+}
+
+/// Best-effort hostname for the default advertised name.
+fn default_name() -> String {
+    std::env::var("HOSTNAME")
+        .ok()
+        .filter(|h| !h.trim().is_empty())
+        .unwrap_or_else(|| "buzz-spawner".to_string())
+}
+
+/// Read an optional env var, treating an empty value as absent.
+fn non_empty_env(key: &str) -> Option<String> {
+    std::env::var(key)
+        .ok()
+        .map(|v| v.trim().to_string())
+        .filter(|v| !v.is_empty())
+}
+
+fn require_env(key: &str) -> Result<String> {
+    std::env::var(key).with_context(|| format!("{key} is required"))
+}
+
+fn parse_env<T: std::str::FromStr>(key: &str, default: T) -> Result<T>
+where
+    T::Err: std::fmt::Display,
+{
+    match std::env::var(key) {
+        Ok(raw) => raw
+            .trim()
+            .parse()
+            .map_err(|e| anyhow::anyhow!("{key} is not a valid value: {e}")),
+        Err(_) => Ok(default),
+    }
+}
+
+/// Parse `BUZZ_SPAWNER_AGENT_ENV`, a comma-separated list of variable *names*
+/// to forward from the spawner's own environment into agent containers.
+///
+/// Names, not `KEY=VALUE` pairs: this keeps secrets out of the compose file's
+/// inline environment block and out of `docker inspect` on the spawner, and it
+/// means a rotated credential is picked up by restarting the spawner rather than
+/// by editing config. A named variable that is unset in the spawner's
+/// environment is a hard error rather than a silent omission — an agent that
+/// boots without its API key fails deep inside the harness with a confusing
+/// message, so it is better to refuse at startup.
+fn parse_agent_env(raw: &str) -> Result<Vec<(String, String)>> {
+    let mut out = Vec::new();
+    for name in raw.split(',').map(str::trim).filter(|n| !n.is_empty()) {
+        if !name
+            .chars()
+            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
+        {
+            bail!("BUZZ_SPAWNER_AGENT_ENV entry {name:?} is not a valid environment variable name");
+        }
+        let value = std::env::var(name)
+            .with_context(|| format!("BUZZ_SPAWNER_AGENT_ENV names {name}, but it is not set"))?;
+        out.push((name.to_string(), value));
+    }
+    Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn agent_env_rejects_malformed_names() {
+        assert!(parse_agent_env("not-a-var").is_err());
+        assert!(parse_agent_env("ANTHROPIC_API_KEY=inline-secret").is_err());
+    }
+
+    #[test]
+    fn agent_env_is_empty_when_unset() {
+        assert!(parse_agent_env("").unwrap().is_empty());
+        assert!(parse_agent_env("  ,  ").unwrap().is_empty());
+    }
+}
diff --git a/crates/buzz-spawner/src/container.rs b/crates/buzz-spawner/src/container.rs
new file mode 100644
index 0000000000..cc3b2123a4
--- /dev/null
+++ b/crates/buzz-spawner/src/container.rs
@@ -0,0 +1,398 @@
+//! Container lifecycle: the [`ContainerOps`] boundary and its Docker backend.
+//!
+//! Agents run shell and file-edit tools through `buzz-dev-mcp`, which is
+//! arbitrary code execution by design. Running several of them as plain
+//! subprocesses of one daemon would let any agent read every other agent's
+//! workspace and the spawner's own state file — including every other agent's
+//! secret key. So each agent gets its own container, its own volume, and its own
+//! resource ceiling.
+//!
+//! Everything that touches Docker sits behind [`ContainerOps`] so the reconciler
+//! can be tested against an in-memory fake.
+
+use std::collections::HashMap;
+use std::time::Duration;
+
+use anyhow::{Context, Result};
+use async_trait::async_trait;
+
+use crate::config::{AGENT_LABEL, SLUG_LABEL, SPAWNER_LABEL};
+
+/// A container the spawner is responsible for.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ManagedContainer {
+    /// Docker container id.
+    pub id: String,
+    /// Container name.
+    pub name: String,
+    /// Agent pubkey from the `com.buzz.agent` label.
+    pub agent_pubkey: String,
+    /// Spec slug from the `com.buzz.spec-slug` label.
+    pub slug: String,
+    /// Whether the container is currently running.
+    pub running: bool,
+}
+
+/// Everything needed to create one agent container.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ContainerSpec {
+    /// Container name.
+    pub name: String,
+    /// Image reference.
+    pub image: String,
+    /// Agent pubkey, applied as a label for reconciliation.
+    pub agent_pubkey: String,
+    /// Spec slug, applied as a label.
+    pub slug: String,
+    /// Spawner pubkey, applied as a label so co-tenant spawners on one host do
+    /// not reap each other's containers.
+    pub spawner_pubkey: String,
+    /// Full environment for the harness process.
+    pub env: Vec<(String, String)>,
+    /// CPU allocation in thousandths of a core.
+    pub cpu_millis: u32,
+    /// Memory limit in mebibytes.
+    pub memory_mib: u32,
+    /// Named volume mounted at the agent's nest directory.
+    pub volume_name: String,
+}
+
+impl ContainerSpec {
+    /// Docker labels identifying this container's owner and purpose.
+    pub fn labels(&self) -> HashMap<String, String> {
+        HashMap::from([
+            (AGENT_LABEL.to_string(), self.agent_pubkey.clone()),
+            (SLUG_LABEL.to_string(), self.slug.clone()),
+            (SPAWNER_LABEL.to_string(), self.spawner_pubkey.clone()),
+        ])
+    }
+}
+
+/// The container operations the reconciler needs.
+#[async_trait]
+pub trait ContainerOps: Send + Sync {
+    /// List every container labelled as belonging to `spawner_pubkey`,
+    /// running or not.
+    async fn list(&self, spawner_pubkey: &str) -> Result<Vec<ManagedContainer>>;
+
+    /// Create and start a container.
+    async fn create(&self, spec: &ContainerSpec) -> Result<String>;
+
+    /// Stop and remove a container, along with its volume when `purge_volume`
+    /// is set. Removing a container the caller believes is gone is not an error.
+    async fn remove(&self, container_id: &str, purge_volume: Option<&str>) -> Result<()>;
+
+    /// Tail the last `lines` of a container's logs.
+    async fn logs(&self, container_id: &str, lines: usize) -> Result<String>;
+}
+
+/// Timeout for a container listing.
+///
+/// Every Docker call is time-boxed. bollard will wait indefinitely on a
+/// half-open socket, and the reconcile loop awaits these calls inline — so one
+/// stalled request (a daemon restart, a laptop resuming from sleep) hangs the
+/// entire daemon *permanently and silently*: no ticker, no relay reads, no
+/// status updates, every agent frozen in whatever phase it was in. A timeout
+/// turns that into an error the loop logs and retries on the next pass.
+const LIST_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// Timeout for creating a container. Generous because it may pull an image.
+const CREATE_TIMEOUT: Duration = Duration::from_secs(600);
+
+/// Timeout for removing a container and optionally its volume.
+const REMOVE_TIMEOUT: Duration = Duration::from_secs(120);
+
+/// Timeout for reading container logs.
+const LOGS_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// Run a Docker operation under a deadline, naming it in the error.
+async fn with_timeout<T>(
+    what: &str,
+    limit: Duration,
+    op: impl std::future::Future<Output = Result<T>>,
+) -> Result<T> {
+    match tokio::time::timeout(limit, op).await {
+        Ok(result) => result,
+        Err(_) => anyhow::bail!(
+            "docker {what} timed out after {}s; the daemon may be unreachable",
+            limit.as_secs()
+        ),
+    }
+}
+
+/// The Docker-socket backend.
+pub struct DockerOps {
+    docker: bollard::Docker,
+}
+
+impl DockerOps {
+    /// Connect to the local Docker daemon over its Unix socket (or the platform
+    /// default), honoring `DOCKER_HOST` when set.
+    pub fn connect() -> Result<Self> {
+        let docker = bollard::Docker::connect_with_defaults()
+            .context("failed to connect to the Docker daemon")?;
+        Ok(Self { docker })
+    }
+}
+
+#[async_trait]
+impl ContainerOps for DockerOps {
+    async fn list(&self, spawner_pubkey: &str) -> Result<Vec<ManagedContainer>> {
+        with_timeout("list", LIST_TIMEOUT, self.list_inner(spawner_pubkey)).await
+    }
+
+    async fn create(&self, spec: &ContainerSpec) -> Result<String> {
+        with_timeout("create", CREATE_TIMEOUT, self.create_inner(spec)).await
+    }
+
+    async fn remove(&self, container_id: &str, purge_volume: Option<&str>) -> Result<()> {
+        with_timeout(
+            "remove",
+            REMOVE_TIMEOUT,
+            self.remove_inner(container_id, purge_volume),
+        )
+        .await
+    }
+
+    async fn logs(&self, container_id: &str, lines: usize) -> Result<String> {
+        with_timeout("logs", LOGS_TIMEOUT, self.logs_inner(container_id, lines)).await
+    }
+}
+
+impl DockerOps {
+    async fn list_inner(&self, spawner_pubkey: &str) -> Result<Vec<ManagedContainer>> {
+        use bollard::query_parameters::ListContainersOptions;
+
+        let mut filters = HashMap::new();
+        filters.insert(
+            "label".to_string(),
+            vec![format!("{SPAWNER_LABEL}={spawner_pubkey}")],
+        );
+
+        let containers = self
+            .docker
+            .list_containers(Some(ListContainersOptions {
+                all: true,
+                filters: Some(filters),
+                ..Default::default()
+            }))
+            .await
+            .context("failed to list agent containers")?;
+
+        Ok(containers
+            .into_iter()
+            .filter_map(|c| {
+                let labels = c.labels.unwrap_or_default();
+                Some(ManagedContainer {
+                    id: c.id?,
+                    name: c
+                        .names
+                        .and_then(|n| n.first().cloned())
+                        .unwrap_or_default()
+                        .trim_start_matches('/')
+                        .to_string(),
+                    // A container carrying our spawner label but missing the
+                    // agent label is malformed; skipping it is safer than
+                    // guessing an identity for something we would then act on.
+                    agent_pubkey: labels.get(AGENT_LABEL)?.clone(),
+                    slug: labels.get(SLUG_LABEL).cloned().unwrap_or_default(),
+                    running: c.state.as_ref().is_some_and(|s| {
+                        matches!(s, bollard::models::ContainerSummaryStateEnum::RUNNING)
+                    }),
+                })
+            })
+            .collect())
+    }
+
+    async fn create_inner(&self, spec: &ContainerSpec) -> Result<String> {
+        use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType};
+        use bollard::query_parameters::{
+            CreateContainerOptions, CreateImageOptionsBuilder, StartContainerOptions,
+        };
+        use futures_util::StreamExt;
+
+        // Pull so a missing image surfaces as a clear status error rather than a
+        // create failure the owner cannot interpret.
+        //
+        // A pull failure is only fatal when the image is also absent locally. An
+        // operator running a locally-built image — an air-gapped host, a
+        // pre-loaded tarball, or an image built on the box itself — has nothing
+        // to pull from, and failing there would make those setups impossible.
+        // Checking local presence second also means a transient registry outage
+        // does not stop an already-cached image from starting.
+        let mut pull = self.docker.create_image(
+            Some(
+                CreateImageOptionsBuilder::default()
+                    .from_image(&spec.image)
+                    .build(),
+            ),
+            None,
+            None,
+        );
+        let mut pull_error: Option<bollard::errors::Error> = None;
+        while let Some(chunk) = pull.next().await {
+            if let Err(e) = chunk {
+                pull_error = Some(e);
+                break;
+            }
+        }
+        if let Some(e) = pull_error {
+            if self.docker.inspect_image(&spec.image).await.is_err() {
+                return Err(e).with_context(|| {
+                    format!(
+                        "failed to pull image {} and it is not present locally",
+                        spec.image
+                    )
+                });
+            }
+            tracing::debug!(
+                image = %spec.image,
+                "image pull failed but a local copy exists; using it: {e}"
+            );
+        }
+
+        let host_config = HostConfig {
+            // NanoCPUs is billionths of a core; cpu_millis is thousandths.
+            nano_cpus: Some(i64::from(spec.cpu_millis) * 1_000_000),
+            memory: Some(i64::from(spec.memory_mib) * 1024 * 1024),
+            mounts: Some(vec![Mount {
+                target: Some("/home/agent/.buzz".to_string()),
+                source: Some(spec.volume_name.clone()),
+                typ: Some(MountType::VOLUME),
+                ..Default::default()
+            }]),
+            restart_policy: None,
+            ..Default::default()
+        };
+
+        let body = ContainerCreateBody {
+            image: Some(spec.image.clone()),
+            env: Some(
+                spec.env
+                    .iter()
+                    .map(|(k, v)| format!("{k}={v}"))
+                    .collect::<Vec<_>>(),
+            ),
+            labels: Some(spec.labels()),
+            host_config: Some(host_config),
+            ..Default::default()
+        };
+
+        let created = self
+            .docker
+            .create_container(
+                Some(CreateContainerOptions {
+                    name: Some(spec.name.clone()),
+                    ..Default::default()
+                }),
+                body,
+            )
+            .await
+            .with_context(|| format!("failed to create container {}", spec.name))?;
+
+        self.docker
+            .start_container(&created.id, None::<StartContainerOptions>)
+            .await
+            .with_context(|| format!("failed to start container {}", spec.name))?;
+
+        Ok(created.id)
+    }
+
+    async fn remove_inner(&self, container_id: &str, purge_volume: Option<&str>) -> Result<()> {
+        use bollard::query_parameters::{RemoveContainerOptions, RemoveVolumeOptions};
+
+        // force stops a running container; the reconciler's intent is "gone",
+        // and a graceful stop is the harness's job via the shutdown convention.
+        let removed = self
+            .docker
+            .remove_container(
+                container_id,
+                Some(RemoveContainerOptions {
+                    force: true,
+                    v: true,
+                    ..Default::default()
+                }),
+            )
+            .await;
+
+        // A container that is already gone is the desired state, not an error.
+        if let Err(e) = removed {
+            if !is_not_found(&e) {
+                return Err(e)
+                    .with_context(|| format!("failed to remove container {container_id}"));
+            }
+        }
+
+        if let Some(volume) = purge_volume {
+            let removed = self
+                .docker
+                .remove_volume(volume, None::<RemoveVolumeOptions>)
+                .await;
+            if let Err(e) = removed {
+                if !is_not_found(&e) {
+                    return Err(e).with_context(|| format!("failed to remove volume {volume}"));
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    async fn logs_inner(&self, container_id: &str, lines: usize) -> Result<String> {
+        use bollard::query_parameters::LogsOptionsBuilder;
+        use futures_util::StreamExt;
+
+        let mut stream = self.docker.logs(
+            container_id,
+            Some(
+                LogsOptionsBuilder::default()
+                    .stdout(true)
+                    .stderr(true)
+                    .tail(&lines.to_string())
+                    .build(),
+            ),
+        );
+
+        let mut out = String::new();
+        while let Some(chunk) = stream.next().await {
+            let chunk = chunk.with_context(|| format!("failed to read logs for {container_id}"))?;
+            out.push_str(&chunk.to_string());
+        }
+        Ok(out)
+    }
+}
+
+fn is_not_found(e: &bollard::errors::Error) -> bool {
+    matches!(
+        e,
+        bollard::errors::Error::DockerResponseServerError {
+            status_code: 404,
+            ..
+        }
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn labels_carry_agent_slug_and_spawner() {
+        let spec = ContainerSpec {
+            name: "buzz-agent-abc-fizz".into(),
+            image: "img".into(),
+            agent_pubkey: "a".repeat(64),
+            slug: "fizz".into(),
+            spawner_pubkey: "s".repeat(64),
+            env: vec![],
+            cpu_millis: 1000,
+            memory_mib: 2048,
+            volume_name: "vol".into(),
+        };
+        let labels = spec.labels();
+        assert_eq!(labels.get(AGENT_LABEL), Some(&"a".repeat(64)));
+        assert_eq!(labels.get(SLUG_LABEL), Some(&"fizz".to_string()));
+        // Without this, two spawners on one host reap each other's containers.
+        assert_eq!(labels.get(SPAWNER_LABEL), Some(&"s".repeat(64)));
+    }
+}
diff --git a/crates/buzz-spawner/src/daemon.rs b/crates/buzz-spawner/src/daemon.rs
new file mode 100644
index 0000000000..2f8d9c7f4b
--- /dev/null
+++ b/crates/buzz-spawner/src/daemon.rs
@@ -0,0 +1,851 @@
+//! The reconcile loop: applies [`crate::reconcile::plan`] actions against the
+//! relay, the store, and the container backend.
+
+use std::{collections::HashMap, sync::Arc};
+
+use anyhow::{Context, Result};
+use buzz_sdk::spawner::{SpawnPhase, SpawnerAgentSpec, SpawnerAgentStatus, SpawnerAnnouncement};
+use nostr::{Keys, PublicKey};
+use tracing::{error, info, warn};
+
+use crate::{
+    attestation::{self, ResponseOutcome},
+    config::{Config, DEFAULT_CPU_MILLIS, DEFAULT_MEMORY_MIB},
+    container::{ContainerOps, ContainerSpec},
+    env::{build_agent_env, AgentRuntime, ResolvedPrompt},
+    reconcile::{plan, Action, DesiredAgent, ReconcileInput},
+    relay::{Inbound, SpawnerRelay},
+    store::{AgentRecord, Store},
+};
+
+/// Long-running spawner daemon.
+pub struct Daemon {
+    config: Config,
+    store: Store,
+    relay: SpawnerRelay,
+    containers: Arc<dyn ContainerOps>,
+    /// Latest spec per `(owner, slug)`, the desired state built from the relay.
+    desired: HashMap<(String, String), DesiredAgent>,
+    /// Newest `created_at` seen per `(owner, slug)`.
+    ///
+    /// Kind 30178 is replaceable, but the relay retains superseded revisions and
+    /// replays them in an order this client does not control. Without this
+    /// guard an older revision silently overwrites a newer one — which loses a
+    /// relocation request, and the spawner keeps running the identity it minted
+    /// instead of the agent the owner asked it to adopt.
+    spec_seen_at: HashMap<(String, String), u64>,
+    /// Whether the relay has finished replaying stored specs.
+    ///
+    /// Until then `desired` is incomplete, and an absent spec must not be read
+    /// as a deletion — see [`ReconcileInput::desired_hydrated`].
+    desired_hydrated: bool,
+}
+
+impl Daemon {
+    /// Connect to the relay and open the state store.
+    pub async fn start(config: Config, containers: Arc<dyn ContainerOps>) -> Result<Self> {
+        let store = Store::open(&config.state_dir)?;
+        let relay = SpawnerRelay::connect(&config.relay_url, &config.keys).await?;
+
+        info!(
+            spawner = %config.keys.public_key().to_hex(),
+            relay = %config.relay_url,
+            "buzz-spawner connected"
+        );
+
+        Ok(Self {
+            config,
+            store,
+            relay,
+            containers,
+            desired: HashMap::new(),
+            spec_seen_at: HashMap::new(),
+            desired_hydrated: false,
+        })
+    }
+
+    /// Run until cancelled.
+    pub async fn run(&mut self, shutdown: tokio::sync::watch::Receiver<bool>) -> Result<()> {
+        let mut ticker = tokio::time::interval(self.config.reconcile_interval);
+        let mut shutdown = shutdown;
+
+        loop {
+            tokio::select! {
+                _ = shutdown.changed() => {
+                    if *shutdown.borrow() {
+                        info!("shutdown requested; leaving agent containers running");
+                        return Ok(());
+                    }
+                }
+                _ = ticker.tick() => {
+                    if let Err(e) = self.reconcile().await {
+                        error!("reconcile pass failed: {e:#}");
+                    }
+                }
+                inbound = self.relay.next() => {
+                    match inbound {
+                        Ok(inbound) => {
+                            if let Err(e) = self.handle_inbound(inbound).await {
+                                warn!("failed to handle relay frame: {e:#}");
+                            }
+                        }
+                        Err(e) => {
+                            // A dropped socket is expected over a long run.
+                            // Reconnect rather than exiting: agent containers
+                            // keep serving while the control plane recovers.
+                            warn!("relay stream error, reconnecting: {e:#}");
+                            if let Err(e) = self.relay.reconnect().await {
+                                error!("reconnect failed: {e:#}");
+                                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    async fn handle_inbound(&mut self, inbound: Inbound) -> Result<()> {
+        match inbound {
+            Inbound::Idle => Ok(()),
+            Inbound::SpecsHydrated => {
+                if !self.desired_hydrated {
+                    info!(
+                        specs = self.desired.len(),
+                        "desired state hydrated; deletions now enabled"
+                    );
+                    self.desired_hydrated = true;
+                }
+                self.reconcile().await
+            }
+            Inbound::Spec {
+                owner_pubkey,
+                desired,
+                created_at,
+            } => {
+                let key = (owner_pubkey, desired.slug.clone());
+                if !self.accept_revision(&key, created_at) {
+                    return Ok(());
+                }
+                self.desired.insert(key, desired);
+                self.reconcile().await
+            }
+            Inbound::SpecDeleted {
+                owner_pubkey,
+                slug,
+                created_at,
+            } => {
+                let key = (owner_pubkey, slug);
+                if !self.accept_revision(&key, created_at) {
+                    return Ok(());
+                }
+                self.desired.remove(&key);
+                self.reconcile().await
+            }
+            Inbound::Attestation { sender, frame } => {
+                if let buzz_sdk::spawner::AttestationFrame::PromptUpdate {
+                    agent_pubkey,
+                    prompt,
+                    ..
+                } = &frame
+                {
+                    return self
+                        .apply_prompt_update(&sender, agent_pubkey, prompt)
+                        .await;
+                }
+                self.apply_attestation(&sender, &frame).await
+            }
+        }
+    }
+
+    async fn apply_attestation(
+        &mut self,
+        sender: &PublicKey,
+        frame: &buzz_sdk::spawner::AttestationFrame,
+    ) -> Result<()> {
+        let Some(record) = self
+            .store
+            .find_by_agent_pubkey(frame.agent_pubkey())
+            .cloned()
+        else {
+            // Not ours, or already torn down. Silently ignoring is correct:
+            // anyone can address a frame to this spawner.
+            return Ok(());
+        };
+
+        match attestation::evaluate_response(&record, sender, frame) {
+            Ok(ResponseOutcome::Accept {
+                auth_tag,
+                prompt,
+                private_key_nsec,
+            }) => {
+                info!(
+                    slug = %record.slug,
+                    agent = %record.agent_pubkey,
+                    "attestation accepted"
+                );
+                self.store.update(&record.owner_pubkey, &record.slug, |r| {
+                    r.auth_tag = Some(auth_tag);
+                    r.pending_nonce = None;
+                    r.attestation_sent_at = None;
+                    // Only overwrite when the owner actually sent something —
+                    // a shared-persona deployment sends no prompt, and must not
+                    // clear one delivered earlier.
+                    if prompt.as_ref().is_some_and(|p| !p.is_empty()) {
+                        r.prompt = prompt;
+                    }
+                    // A relocated agent arrives with its own key. Verified
+                    // against the attested pubkey in `evaluate_response`, so by
+                    // here it is known to be the right identity.
+                    if let Some(nsec) = private_key_nsec {
+                        r.private_key_nsec = nsec;
+                    }
+                })?;
+                self.reconcile().await
+            }
+            Ok(ResponseOutcome::Rejected { reason }) => {
+                warn!(slug = %record.slug, "owner declined attestation: {reason}");
+                self.store.update(&record.owner_pubkey, &record.slug, |r| {
+                    r.pending_nonce = None;
+                    r.attestation_sent_at = None;
+                })?;
+                self.publish_status(
+                    &record.slug,
+                    &record.owner_pubkey,
+                    SpawnPhase::Failed,
+                    Some(&record.agent_pubkey),
+                    None,
+                    Some(format!("owner declined attestation: {reason}")),
+                    0,
+                )
+                .await
+            }
+            Err(e) => {
+                // Rejected frames are not an error condition for the daemon —
+                // anyone can send one. Log and carry on.
+                warn!(
+                    slug = %record.slug,
+                    sender = %sender.to_hex(),
+                    "rejected attestation frame: {e:#}"
+                );
+                Ok(())
+            }
+        }
+    }
+
+    /// Whether a spec revision is newer than what is already held.
+    ///
+    /// Ties are accepted: two revisions can share a second, and refusing both
+    /// would strand whichever arrived first.
+    fn accept_revision(&mut self, key: &(String, String), created_at: u64) -> bool {
+        if self
+            .spec_seen_at
+            .get(key)
+            .is_some_and(|seen| created_at < *seen)
+        {
+            return false;
+        }
+        self.spec_seen_at.insert(key.clone(), created_at);
+        true
+    }
+
+    /// Apply replacement prompt material for an agent after a persona edit.
+    ///
+    /// Accepted only from the agent's own owner. There is no nonce to check —
+    /// this opens no handshake round — so ownership is the entire gate: without
+    /// it anyone could rewrite a running agent's instructions.
+    async fn apply_prompt_update(
+        &mut self,
+        sender: &PublicKey,
+        agent_pubkey: &str,
+        prompt: &buzz_sdk::spawner::PromptMaterial,
+    ) -> Result<()> {
+        let Some(record) = self.store.find_by_agent_pubkey(agent_pubkey).cloned() else {
+            return Ok(());
+        };
+        if sender.to_hex() != record.owner_pubkey {
+            warn!(
+                agent = %agent_pubkey,
+                sender = %sender.to_hex(),
+                "ignoring prompt update from a non-owner"
+            );
+            return Ok(());
+        }
+        if prompt.is_empty() {
+            return Ok(());
+        }
+
+        info!(slug = %record.slug, "prompt updated by owner");
+        self.store.update(&record.owner_pubkey, &record.slug, |r| {
+            r.prompt = Some(prompt.clone());
+            // Force a restart: the container bakes the prompt into its env, so
+            // a new prompt only takes effect on a fresh container.
+            r.spec_hash = None;
+        })?;
+        self.reconcile().await
+    }
+
+    /// One reconciliation pass.
+    pub async fn reconcile(&mut self) -> Result<()> {
+        let spawner_pubkey = self.config.keys.public_key().to_hex();
+        let containers = self.containers.list(&spawner_pubkey).await?;
+        let desired: Vec<DesiredAgent> = self.desired.values().cloned().collect();
+        let records: Vec<AgentRecord> = self.store.agents().cloned().collect();
+
+        let actions = plan(ReconcileInput {
+            desired: &desired,
+            records: &records,
+            containers: &containers,
+            now: chrono::Utc::now().timestamp(),
+            attestation_timeout_secs: self.config.attestation_timeout.as_secs() as i64,
+            max_agents: self.config.max_agents,
+            desired_hydrated: self.desired_hydrated,
+        });
+
+        for action in actions {
+            // One failing agent must not abort the pass for every other agent.
+            if let Err(e) = self.apply(action).await {
+                error!("action failed: {e:#}");
+            }
+        }
+
+        // Advertise after acting, so the published count reflects this pass.
+        // A failure here is not fatal: discovery is a convenience, and an
+        // un-advertised spawner still works for anyone who knows its pubkey.
+        if let Err(e) = self.announce(containers.len()).await {
+            warn!("failed to publish spawner announcement: {e:#}");
+        }
+        Ok(())
+    }
+
+    /// Publish this spawner's self-description and current capacity.
+    async fn announce(&mut self, agents_running: usize) -> Result<()> {
+        let announcement = SpawnerAnnouncement {
+            name: self.config.name.clone(),
+            description: self.config.description.clone(),
+            agent_image: Some(self.config.agent_image.clone()),
+            // What this host actually runs, so a picker can say "Claude Code"
+            // rather than leaving the user guessing.
+            runtime: self.config.agent_command.clone(),
+            max_agents: self.config.max_agents as u32,
+            agents_running: agents_running as u32,
+            max_cpu_millis: Some(self.config.max_cpu_millis),
+            max_memory_mib: Some(self.config.max_memory_mib),
+        };
+        self.relay.publish_announcement(&announcement).await
+    }
+
+    async fn apply(&mut self, action: Action) -> Result<()> {
+        match action {
+            Action::Provision { desired } => self.provision(&desired).await,
+            Action::ReRequestAttestation { owner_pubkey, slug } => {
+                self.request_attestation(&owner_pubkey, &slug).await
+            }
+            Action::Start { desired } => self.start_agent(&desired, None, true).await,
+            Action::Restart {
+                desired,
+                container_id,
+                crashed,
+            } => {
+                if crashed {
+                    // Count the crash before restarting, so backoff grows even
+                    // though the *creation* that follows will succeed. The
+                    // container's own logs are the only place the reason
+                    // exists — surface a tail of them rather than making the
+                    // owner SSH into the host to find out why.
+                    let reason = self.crash_reason(&container_id).await;
+                    if let Some(record) = self
+                        .store
+                        .get(&desired.owner_pubkey, &desired.slug)
+                        .cloned()
+                    {
+                        self.record_start_failure(&desired, &record, reason).await?;
+                    }
+                }
+                self.start_agent(&desired, Some(&container_id), !crashed)
+                    .await
+            }
+            Action::Stop {
+                owner_pubkey,
+                slug,
+                container_id,
+            } => {
+                // Volume preserved: a disabled agent keeps its workspace so
+                // re-enabling resumes rather than starting from nothing.
+                self.containers.remove(&container_id, None).await?;
+                self.store.update(&owner_pubkey, &slug, |r| {
+                    r.spec_hash = None;
+                })?;
+                self.publish_status(
+                    &slug,
+                    &owner_pubkey,
+                    SpawnPhase::Stopped,
+                    None,
+                    None,
+                    None,
+                    0,
+                )
+                .await
+            }
+            Action::Delete {
+                owner_pubkey,
+                slug,
+                container_id,
+            } => {
+                let volume = volume_name(&owner_pubkey, &slug);
+                if let Some(id) = container_id {
+                    self.containers.remove(&id, Some(&volume)).await?;
+                }
+                self.store.remove(&owner_pubkey, &slug)?;
+                // Clear the status too, or clients keep a row for an agent that
+                // is gone — stuck in whatever phase it died in.
+                if let Err(e) = self.relay.tombstone_status(&slug, &owner_pubkey).await {
+                    warn!(slug = %slug, "failed to tombstone status: {e:#}");
+                }
+                info!(slug = %slug, "agent deleted");
+                Ok(())
+            }
+            Action::RemoveOrphan { container_id } => {
+                warn!(container = %container_id, "removing container with no spawner record");
+                self.containers.remove(&container_id, None).await
+            }
+        }
+    }
+
+    async fn provision(&mut self, desired: &DesiredAgent) -> Result<()> {
+        // A spec that names an identity is relocating an existing agent, not
+        // creating one. Its pubkey carries the agent's channels, profile, and
+        // NIP-AE memory — none of which survive a new key — so the record is
+        // created around that pubkey and the secret arrives over the encrypted
+        // handshake. Until it does the record has no key and is not startable
+        // (`AgentRecord::is_attested`).
+        if let Some(agent_pubkey) = desired.spec.agent_pubkey.as_deref() {
+            let record = AgentRecord {
+                slug: desired.slug.clone(),
+                owner_pubkey: desired.owner_pubkey.clone(),
+                agent_pubkey: agent_pubkey.to_ascii_lowercase(),
+                private_key_nsec: String::new(),
+                auth_tag: None,
+                pending_nonce: None,
+                attestation_sent_at: None,
+                spec_hash: None,
+                prompt: None,
+                restart_count: 0,
+                last_failure_at: None,
+            };
+            info!(
+                slug = %record.slug,
+                agent = %record.agent_pubkey,
+                "adopting an existing agent; requesting its key from the owner"
+            );
+            self.store.put(record)?;
+            return self
+                .request_attestation(&desired.owner_pubkey, &desired.slug)
+                .await;
+        }
+
+        let keys = Keys::generate();
+        let record = AgentRecord {
+            slug: desired.slug.clone(),
+            owner_pubkey: desired.owner_pubkey.clone(),
+            agent_pubkey: keys.public_key().to_hex(),
+            private_key_nsec: {
+                use nostr::nips::nip19::ToBech32;
+                keys.secret_key()
+                    .to_bech32()
+                    .context("failed to encode agent secret key")?
+            },
+            auth_tag: None,
+            pending_nonce: None,
+            attestation_sent_at: None,
+            spec_hash: None,
+            prompt: None,
+            restart_count: 0,
+            last_failure_at: None,
+        };
+
+        info!(
+            slug = %record.slug,
+            agent = %record.agent_pubkey,
+            "minted agent key; requesting owner attestation"
+        );
+        // Persist before sending: a crash between the two must not lose the key
+        // for a pubkey the owner is about to attest.
+        self.store.put(record)?;
+        self.request_attestation(&desired.owner_pubkey, &desired.slug)
+            .await
+    }
+
+    async fn request_attestation(&mut self, owner_pubkey: &str, slug: &str) -> Result<()> {
+        let Some(record) = self.store.get(owner_pubkey, slug).cloned() else {
+            return Ok(());
+        };
+        let owner = PublicKey::parse(owner_pubkey).context("invalid owner pubkey")?;
+
+        let nonce = attestation::new_nonce();
+        let frame = buzz_sdk::spawner::AttestationFrame::Request {
+            spec_slug: record.slug.clone(),
+            agent_pubkey: record.agent_pubkey.clone(),
+            conditions: String::new(),
+            nonce: nonce.clone(),
+        };
+
+        self.relay.send_attestation(&owner, &frame).await?;
+        self.store.update(owner_pubkey, slug, |r| {
+            r.pending_nonce = Some(nonce);
+            r.attestation_sent_at = Some(chrono::Utc::now().timestamp());
+        })?;
+
+        self.publish_status(
+            slug,
+            owner_pubkey,
+            SpawnPhase::PendingAttestation,
+            Some(&record.agent_pubkey),
+            None,
+            None,
+            record.restart_count,
+        )
+        .await
+    }
+
+    /// Create (or replace) an agent's container.
+    ///
+    /// `reset_failures` is false when restarting after a crash. Creating a
+    /// container always succeeds even when the process inside it exits
+    /// immediately, so clearing the counters on creation would erase the crash
+    /// that just happened and the backoff would never grow — the container
+    /// would be recreated on every pass forever.
+    async fn start_agent(
+        &mut self,
+        desired: &DesiredAgent,
+        replace: Option<&str>,
+        reset_failures: bool,
+    ) -> Result<()> {
+        let Some(record) = self
+            .store
+            .get(&desired.owner_pubkey, &desired.slug)
+            .cloned()
+        else {
+            return Ok(());
+        };
+
+        if let Some(container_id) = replace {
+            // Volume preserved across a restart: a config change should not
+            // wipe the agent's workspace.
+            self.containers.remove(container_id, None).await?;
+        }
+
+        let hash = desired.spec_hash();
+        // A prompt that will not resolve is a persistent failure, not a
+        // transient one: propagating it here would skip the backoff bookkeeping
+        // below and the reconcile loop would retry on every inbound event,
+        // hammering the relay until it rate-limits us.
+        let prompt = match self.resolve_prompt(desired).await {
+            Ok(prompt) => prompt,
+            Err(e) => {
+                return self
+                    .record_start_failure(desired, &record, format!("{e:#}"))
+                    .await;
+            }
+        };
+        let (cpu_millis, memory_mib) = self.resources_for(&desired.spec);
+
+        let spec = ContainerSpec {
+            name: record.container_name(),
+            image: self.config.agent_image.clone(),
+            agent_pubkey: record.agent_pubkey.clone(),
+            slug: record.slug.clone(),
+            spawner_pubkey: self.config.keys.public_key().to_hex(),
+            env: build_agent_env(
+                &record,
+                &desired.spec,
+                &prompt,
+                &self.config.agent_relay_url,
+                &self.config.agent_env,
+                &AgentRuntime {
+                    command: self.config.agent_command.as_deref(),
+                    args: self.config.agent_args.as_deref(),
+                },
+            ),
+            cpu_millis,
+            memory_mib,
+            volume_name: volume_name(&desired.owner_pubkey, &desired.slug),
+        };
+
+        match self.containers.create(&spec).await {
+            Ok(id) => {
+                info!(slug = %desired.slug, container = %id, "agent started");
+                self.store
+                    .update(&desired.owner_pubkey, &desired.slug, |r| {
+                        r.spec_hash = Some(hash.clone());
+                        if reset_failures {
+                            r.restart_count = 0;
+                            r.last_failure_at = None;
+                        }
+                    })?;
+                if !reset_failures {
+                    // The Failed status published moments ago is the truthful
+                    // one; overwriting it with Running would hide a crash loop
+                    // behind a green badge that flickers.
+                    return Ok(());
+                }
+                self.publish_status(
+                    &desired.slug,
+                    &desired.owner_pubkey,
+                    SpawnPhase::Running,
+                    Some(&record.agent_pubkey),
+                    Some(&hash),
+                    None,
+                    0,
+                )
+                .await
+            }
+            Err(e) => {
+                self.record_start_failure(desired, &record, format!("{e:#}"))
+                    .await
+            }
+        }
+    }
+
+    /// Best-effort explanation for why a container died, from its own logs.
+    async fn crash_reason(&self, container_id: &str) -> String {
+        match self.containers.logs(container_id, 20).await {
+            Ok(logs) => {
+                let tail: String = logs
+                    .lines()
+                    .filter(|l| !l.trim().is_empty())
+                    .rev()
+                    .take(3)
+                    .collect::<Vec<_>>()
+                    .into_iter()
+                    .rev()
+                    .collect::<Vec<_>>()
+                    .join(" | ");
+                if tail.is_empty() {
+                    "container exited without output".to_string()
+                } else {
+                    format!("container exited: {}", truncate(&tail, 900))
+                }
+            }
+            Err(e) => format!("container exited; logs unavailable: {e}"),
+        }
+    }
+
+    /// Record a failed start: bump the backoff counters and report it.
+    ///
+    /// Every path that fails to bring an agent up must go through here. A
+    /// failure that skips it is retried on the next inbound event with no delay,
+    /// which turns one broken agent into a request storm against the relay.
+    async fn record_start_failure(
+        &mut self,
+        desired: &DesiredAgent,
+        record: &AgentRecord,
+        message: String,
+    ) -> Result<()> {
+        error!(slug = %desired.slug, "failed to start agent: {message}");
+        let restart_count = record.restart_count.saturating_add(1);
+        self.store
+            .update(&desired.owner_pubkey, &desired.slug, |r| {
+                r.restart_count = restart_count;
+                r.last_failure_at = Some(chrono::Utc::now().timestamp());
+            })?;
+        self.publish_status(
+            &desired.slug,
+            &desired.owner_pubkey,
+            SpawnPhase::Failed,
+            Some(&record.agent_pubkey),
+            None,
+            Some(message),
+            restart_count,
+        )
+        .await
+    }
+
+    /// Clamp a spec's requested resources to the host's configured ceiling.
+    ///
+    /// Clamped rather than rejected: an owner asking for more than the host
+    /// allows should get a smaller agent, not a broken one.
+    fn resources_for(&self, spec: &SpawnerAgentSpec) -> (u32, u32) {
+        let requested = spec.resources.unwrap_or_default();
+        (
+            requested
+                .cpu_millis
+                .unwrap_or(DEFAULT_CPU_MILLIS)
+                .min(self.config.max_cpu_millis),
+            requested
+                .memory_mib
+                .unwrap_or(DEFAULT_MEMORY_MIB)
+                .min(self.config.max_memory_mib),
+        )
+    }
+
+    /// Resolve prompt material for a spec.
+    ///
+    /// A spec that names a `persona_id` resolves through the owner's kind:30175
+    /// persona, matching how the desktop slims kind:30177 — the prompt lives in
+    /// exactly one place. A definition-less spec carries its own prompt inline.
+    async fn resolve_prompt(&mut self, desired: &DesiredAgent) -> Result<ResolvedPrompt> {
+        let mut prompt = self.resolve_prompt_uncached(desired).await?;
+        // Host defaults fill only what the spec and persona left empty, so an
+        // explicit choice always wins over the operator's fallback.
+        if prompt.provider.is_none() {
+            prompt.provider = self.config.default_provider.clone();
+        }
+        if prompt.model.is_none() {
+            prompt.model = self.config.default_model.clone();
+        }
+        Ok(prompt)
+    }
+
+    async fn resolve_prompt_uncached(&mut self, desired: &DesiredAgent) -> Result<ResolvedPrompt> {
+        // Prompt delivered over the encrypted handshake wins: it is the only
+        // source that works for an unshared persona, and it is what the owner
+        // most recently intended.
+        if let Some(record) = self.store.get(&desired.owner_pubkey, &desired.slug) {
+            if let Some(prompt) = record.prompt.as_ref().filter(|p| !p.is_empty()) {
+                return Ok(ResolvedPrompt {
+                    system_prompt: prompt.system_prompt.clone(),
+                    team_instructions: prompt.team_instructions.clone(),
+                    model: prompt.model.clone(),
+                    provider: prompt.provider.clone(),
+                });
+            }
+        }
+
+        let Some(persona_id) = desired.spec.persona_id.as_deref() else {
+            return Ok(ResolvedPrompt {
+                system_prompt: desired.spec.system_prompt.clone(),
+                team_instructions: None,
+                model: desired.spec.model.clone(),
+                provider: desired.spec.provider.clone(),
+            });
+        };
+
+        let owner = PublicKey::parse(&desired.owner_pubkey).context("invalid owner pubkey")?;
+        let personas = self.relay.fetch_personas(&owner).await?;
+
+        let Some(event) = personas.get(persona_id) else {
+            // Falling back to the inline fields keeps a definition-less spec
+            // working, and surfaces a clear failure for one that genuinely
+            // depends on a persona the spawner cannot read.
+            if desired.spec.system_prompt.is_none() {
+                // Kind 30175 is author-only unless tagged ["shared","true"], and
+                // the spawner is not the author. It authenticates as itself with
+                // no NIP-OA attestation, so no owner delegation applies to it —
+                // the two ways out are both the owner's to take.
+                anyhow::bail!(
+                    "no prompt for persona {persona_id}: it is not readable by \
+                     this spawner and none was delivered over the attestation \
+                     handshake. Re-approve the agent from a client that sends \
+                     prompt material, or publish the persona with \
+                     [\"shared\",\"true\"]."
+                );
+            }
+            warn!(
+                persona = %persona_id,
+                "persona not found; falling back to the spec's inline prompt"
+            );
+            return Ok(ResolvedPrompt {
+                system_prompt: desired.spec.system_prompt.clone(),
+                team_instructions: None,
+                model: desired.spec.model.clone(),
+                provider: desired.spec.provider.clone(),
+            });
+        };
+
+        let body: PersonaContent = serde_json::from_str(event.content.as_ref())
+            .with_context(|| format!("failed to parse persona {persona_id}"))?;
+
+        Ok(ResolvedPrompt {
+            system_prompt: body
+                .system_prompt
+                .or_else(|| desired.spec.system_prompt.clone()),
+            team_instructions: body.team_instructions,
+            model: body.model.or_else(|| desired.spec.model.clone()),
+            provider: body.provider.or_else(|| desired.spec.provider.clone()),
+        })
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    async fn publish_status(
+        &mut self,
+        slug: &str,
+        owner_pubkey: &str,
+        phase: SpawnPhase,
+        agent_pubkey: Option<&str>,
+        spec_hash: Option<&str>,
+        error: Option<String>,
+        restart_count: u32,
+    ) -> Result<()> {
+        let status = SpawnerAgentStatus {
+            phase,
+            agent_pubkey: agent_pubkey.map(str::to_string),
+            spec_hash: spec_hash.map(str::to_string),
+            error,
+            restart_count,
+        };
+        self.relay.publish_status(slug, owner_pubkey, &status).await
+    }
+}
+
+/// Truncate on a char boundary so a multi-byte log tail cannot panic.
+fn truncate(s: &str, max: usize) -> String {
+    if s.len() <= max {
+        return s.to_string();
+    }
+    let mut end = max;
+    while !s.is_char_boundary(end) {
+        end -= 1;
+    }
+    format!("{}…", &s[..end])
+}
+
+/// The subset of a kind:30175 persona body the spawner needs.
+///
+/// Deliberately narrow: the spawner reads a prompt, not a whole persona record,
+/// and unknown fields are dropped rather than carried into the container env.
+#[derive(serde::Deserialize)]
+struct PersonaContent {
+    #[serde(default)]
+    system_prompt: Option<String>,
+    #[serde(default)]
+    team_instructions: Option<String>,
+    #[serde(default)]
+    model: Option<String>,
+    #[serde(default)]
+    provider: Option<String>,
+}
+
+/// Per-agent volume name, scoped by owner for the same reason container names
+/// are: slugs are only unique per owner.
+fn volume_name(owner_pubkey: &str, slug: &str) -> String {
+    format!(
+        "buzz-agent-{}-{}",
+        &owner_pubkey[..12.min(owner_pubkey.len())],
+        slug
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn volume_names_are_scoped_per_owner() {
+        assert_ne!(
+            volume_name(&"a".repeat(64), "fizz"),
+            volume_name(&"b".repeat(64), "fizz")
+        );
+    }
+
+    #[test]
+    fn persona_content_ignores_unknown_fields() {
+        // A persona event carrying extra keys must not leak them anywhere.
+        let body: PersonaContent = serde_json::from_str(
+            r#"{"system_prompt":"be Fizz","env_vars":{"K":"v"},"private_key_nsec":"nsec1x"}"#,
+        )
+        .unwrap();
+        assert_eq!(body.system_prompt.as_deref(), Some("be Fizz"));
+        assert!(body.model.is_none());
+    }
+}
diff --git a/crates/buzz-spawner/src/env.rs b/crates/buzz-spawner/src/env.rs
new file mode 100644
index 0000000000..ef45dd28c8
--- /dev/null
+++ b/crates/buzz-spawner/src/env.rs
@@ -0,0 +1,376 @@
+//! Assemble the environment for a `buzz-acp` harness container.
+//!
+//! This mirrors the desktop's `spawn_agent_child`
+//! (`desktop/src-tauri/src/managed_agents/runtime.rs`) so a VPS agent and a
+//! laptop agent are configured identically — a Fizz on the server should differ
+//! from a Fizz on a laptop only in where its process happens to run.
+
+use buzz_sdk::spawner::{RespondTo, SpawnerAgentSpec};
+
+use crate::store::AgentRecord;
+
+/// Environment variable names the spawner owns outright.
+///
+/// Operator-supplied passthrough env (`BUZZ_SPAWNER_AGENT_ENV`) is filtered
+/// against this list so a misconfigured compose file cannot hand an agent a
+/// different identity, a different relay, or somebody else's attestation. This
+/// mirrors the desktop's reserved-key strip in
+/// `managed_agents/env_vars.rs`.
+pub const RESERVED_KEYS: &[&str] = &[
+    // Code-execution surface: which binary the harness spawns. Reserved so
+    // operator passthrough cannot set it either — it comes from the spawner's
+    // own `BUZZ_SPAWNER_AGENT_COMMAND`, applied below.
+    "BUZZ_ACP_AGENT_COMMAND",
+    "BUZZ_ACP_AGENT_ARGS",
+    "BUZZ_PRIVATE_KEY",
+    "NOSTR_PRIVATE_KEY",
+    "BUZZ_AUTH_TAG",
+    "BUZZ_API_TOKEN",
+    "BUZZ_RELAY_URL",
+    "BUZZ_ACP_PRIVATE_KEY",
+    "BUZZ_ACP_API_TOKEN",
+    "BUZZ_ACP_SETUP_PAYLOAD",
+];
+
+/// The resolved prompt material for an agent, gathered from its spec and the
+/// persona it references.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ResolvedPrompt {
+    /// The agent's system prompt.
+    pub system_prompt: Option<String>,
+    /// Team-level instructions appended after the system prompt.
+    pub team_instructions: Option<String>,
+    /// Model id, in `provider:model` or bare-model form.
+    pub model: Option<String>,
+    /// Inference provider id.
+    pub provider: Option<String>,
+}
+
+/// Which ACP agent binary the harness runs inside the container.
+///
+/// Operator-supplied only. This selects what executes, so it deliberately has no
+/// path from a kind:30178 spec — those are owner-authored and world-readable.
+pub struct AgentRuntime<'a> {
+    /// ACP agent binary, or `None` to keep the image default.
+    pub command: Option<&'a str>,
+    /// Comma-separated args for `command`.
+    pub args: Option<&'a str>,
+}
+
+/// Build the full environment for an agent container.
+///
+/// `passthrough` is the operator-configured env (LLM credentials and the like).
+/// It is applied *first* so the Buzz-owned variables below always win: an
+/// operator cannot accidentally — or deliberately — override an agent's identity
+/// by naming `BUZZ_PRIVATE_KEY` in the compose file.
+pub fn build_agent_env(
+    record: &AgentRecord,
+    spec: &SpawnerAgentSpec,
+    prompt: &ResolvedPrompt,
+    relay_url: &str,
+    passthrough: &[(String, String)],
+    runtime: &AgentRuntime<'_>,
+) -> Vec<(String, String)> {
+    let mut env: Vec<(String, String)> = passthrough
+        .iter()
+        .filter(|(k, _)| !RESERVED_KEYS.contains(&k.as_str()))
+        .cloned()
+        .collect();
+
+    let mut set = |key: &str, value: String| env.push((key.to_string(), value));
+
+    // Runtime selection, from operator config only — never from a spec.
+    if let Some(command) = runtime.command {
+        set("BUZZ_ACP_AGENT_COMMAND", command.to_string());
+    }
+    if let Some(args) = runtime.args {
+        set("BUZZ_ACP_AGENT_ARGS", args.to_string());
+    }
+
+    // Identity and transport.
+    set("BUZZ_PRIVATE_KEY", record.private_key_nsec.clone());
+    set("BUZZ_RELAY_URL", relay_url.to_string());
+    if let Some(auth_tag) = &record.auth_tag {
+        set("BUZZ_AUTH_TAG", auth_tag.clone());
+    }
+    // git-credential-nostr signs NIP-98 git auth with the same key.
+    set("NOSTR_PRIVATE_KEY", record.private_key_nsec.clone());
+
+    // Prompt material.
+    if let Some(system_prompt) = &prompt.system_prompt {
+        set("BUZZ_ACP_SYSTEM_PROMPT", system_prompt.clone());
+    }
+    if let Some(team_instructions) = &prompt.team_instructions {
+        set("BUZZ_ACP_TEAM_INSTRUCTIONS", team_instructions.clone());
+    }
+    // Two layers read these, and both must be satisfied. `BUZZ_ACP_MODEL` is
+    // the harness's own setting; `BUZZ_AGENT_MODEL`/`BUZZ_AGENT_PROVIDER` are
+    // what the `buzz-agent` binary requires of its own config, and it refuses
+    // to start without them. The desktop makes the same mapping through its
+    // runtime table (`managed_agents/discovery.rs`, `model_env_var` /
+    // `provider_env_var`); a spawner has only one runtime, so it is direct.
+    if let Some(model) = &prompt.model {
+        set("BUZZ_ACP_MODEL", model.clone());
+        set("BUZZ_AGENT_MODEL", model.clone());
+    }
+    if let Some(provider) = &prompt.provider {
+        set("BUZZ_AGENT_PROVIDER", provider.clone());
+    }
+
+    // Behavior.
+    set("BUZZ_ACP_AGENTS", spec.parallelism.to_string());
+    set(
+        "BUZZ_ACP_RESPOND_TO",
+        match spec.respond_to {
+            RespondTo::Anyone => "anyone",
+            RespondTo::OwnerOnly => "owner-only",
+            RespondTo::Allowlist => "allowlist",
+        }
+        .to_string(),
+    );
+    if spec.respond_to == RespondTo::Allowlist {
+        set(
+            "BUZZ_ACP_RESPOND_TO_ALLOWLIST",
+            spec.respond_to_allowlist.join(","),
+        );
+    }
+    set("BUZZ_ACP_RELAY_OBSERVER", "true".to_string());
+    set("BUZZ_ACP_DEDUP", "queue".to_string());
+    set("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer".to_string());
+
+    // Provenance, so an operator inspecting a container can tell where it came
+    // from without cross-referencing the state file.
+    set("BUZZ_SPAWNED_BY", "buzz-spawner".to_string());
+    set("BUZZ_SPAWNER_SPEC_SLUG", record.slug.clone());
+
+    env
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn record() -> AgentRecord {
+        AgentRecord {
+            slug: "fizz-prod".into(),
+            owner_pubkey: "b".repeat(64),
+            agent_pubkey: "a".repeat(64),
+            private_key_nsec: "nsec1realsecret".into(),
+            auth_tag: Some(r#"["auth","owner","","sig"]"#.into()),
+            pending_nonce: None,
+            attestation_sent_at: None,
+            spec_hash: None,
+            prompt: None,
+            restart_count: 0,
+            last_failure_at: None,
+        }
+    }
+
+    fn spec() -> SpawnerAgentSpec {
+        SpawnerAgentSpec {
+            name: "Fizz".into(),
+            agent_pubkey: None,
+            persona_id: Some("builtin:fizz".into()),
+            system_prompt: None,
+            model: None,
+            provider: None,
+            parallelism: 2,
+            respond_to: RespondTo::Anyone,
+            respond_to_allowlist: vec![],
+            resources: None,
+            enabled: true,
+        }
+    }
+
+    /// Image-default runtime: the spawner sets no override.
+    const DEFAULT_RUNTIME: AgentRuntime<'static> = AgentRuntime {
+        command: None,
+        args: None,
+    };
+
+    fn lookup(env: &[(String, String)], key: &str) -> Option<String> {
+        env.iter().rfind(|(k, _)| k == key).map(|(_, v)| v.clone())
+    }
+
+    #[test]
+    fn sets_identity_prompt_and_behavior() {
+        let prompt = ResolvedPrompt {
+            system_prompt: Some("You are Fizz.".into()),
+            team_instructions: None,
+            model: Some("claude-opus-5".into()),
+            provider: Some("anthropic".into()),
+        };
+        let env = build_agent_env(
+            &record(),
+            &spec(),
+            &prompt,
+            "wss://relay.example",
+            &[],
+            &DEFAULT_RUNTIME,
+        );
+
+        assert_eq!(
+            lookup(&env, "BUZZ_PRIVATE_KEY").as_deref(),
+            Some("nsec1realsecret")
+        );
+        assert_eq!(
+            lookup(&env, "BUZZ_RELAY_URL").as_deref(),
+            Some("wss://relay.example")
+        );
+        assert_eq!(
+            lookup(&env, "BUZZ_ACP_SYSTEM_PROMPT").as_deref(),
+            Some("You are Fizz.")
+        );
+        assert_eq!(
+            lookup(&env, "BUZZ_ACP_MODEL").as_deref(),
+            Some("claude-opus-5")
+        );
+        assert_eq!(lookup(&env, "BUZZ_ACP_AGENTS").as_deref(), Some("2"));
+        assert_eq!(
+            lookup(&env, "BUZZ_ACP_RESPOND_TO").as_deref(),
+            Some("anyone")
+        );
+    }
+
+    #[test]
+    fn passthrough_cannot_override_reserved_keys() {
+        // A compose file naming BUZZ_PRIVATE_KEY must not be able to hand an
+        // agent a different identity, nor swap the relay out from under it.
+        let passthrough = vec![
+            ("ANTHROPIC_API_KEY".to_string(), "sk-real".to_string()),
+            ("BUZZ_PRIVATE_KEY".to_string(), "nsec1attacker".to_string()),
+            (
+                "BUZZ_RELAY_URL".to_string(),
+                "wss://evil.example".to_string(),
+            ),
+            (
+                "BUZZ_AUTH_TAG".to_string(),
+                "[\"auth\",\"attacker\"]".to_string(),
+            ),
+        ];
+        let env = build_agent_env(
+            &record(),
+            &spec(),
+            &ResolvedPrompt::default(),
+            "wss://relay.example",
+            &passthrough,
+            &DEFAULT_RUNTIME,
+        );
+
+        assert_eq!(
+            lookup(&env, "ANTHROPIC_API_KEY").as_deref(),
+            Some("sk-real")
+        );
+        assert_eq!(
+            lookup(&env, "BUZZ_PRIVATE_KEY").as_deref(),
+            Some("nsec1realsecret")
+        );
+        assert_eq!(
+            lookup(&env, "BUZZ_RELAY_URL").as_deref(),
+            Some("wss://relay.example")
+        );
+        assert!(!env
+            .iter()
+            .any(|(_, v)| v.contains("attacker") || v.contains("evil.example")));
+    }
+
+    #[test]
+    fn runtime_override_selects_the_agent_binary() {
+        let env = build_agent_env(
+            &record(),
+            &spec(),
+            &ResolvedPrompt::default(),
+            "wss://r",
+            &[],
+            &AgentRuntime {
+                command: Some("claude-agent-acp"),
+                args: None,
+            },
+        );
+        assert_eq!(
+            lookup(&env, "BUZZ_ACP_AGENT_COMMAND").as_deref(),
+            Some("claude-agent-acp")
+        );
+    }
+
+    #[test]
+    fn leaves_the_image_default_when_no_runtime_is_configured() {
+        let env = build_agent_env(
+            &record(),
+            &spec(),
+            &ResolvedPrompt::default(),
+            "wss://r",
+            &[],
+            &DEFAULT_RUNTIME,
+        );
+        assert!(lookup(&env, "BUZZ_ACP_AGENT_COMMAND").is_none());
+    }
+
+    #[test]
+    fn passthrough_cannot_choose_the_agent_binary() {
+        // The binary the harness spawns is a code-execution surface. Operator
+        // passthrough goes through the same reserved-key filter as everything
+        // else, so it has to come from BUZZ_SPAWNER_AGENT_COMMAND.
+        let passthrough = vec![("BUZZ_ACP_AGENT_COMMAND".to_string(), "/bin/sh".to_string())];
+        let env = build_agent_env(
+            &record(),
+            &spec(),
+            &ResolvedPrompt::default(),
+            "wss://r",
+            &passthrough,
+            &AgentRuntime {
+                command: Some("claude-agent-acp"),
+                args: None,
+            },
+        );
+        assert_eq!(
+            lookup(&env, "BUZZ_ACP_AGENT_COMMAND").as_deref(),
+            Some("claude-agent-acp")
+        );
+        assert!(!env.iter().any(|(_, v)| v == "/bin/sh"));
+    }
+
+    #[test]
+    fn omits_the_auth_tag_until_attested() {
+        let mut record = record();
+        record.auth_tag = None;
+        let env = build_agent_env(
+            &record,
+            &spec(),
+            &ResolvedPrompt::default(),
+            "wss://relay.example",
+            &[],
+            &DEFAULT_RUNTIME,
+        );
+        assert!(lookup(&env, "BUZZ_AUTH_TAG").is_none());
+    }
+
+    #[test]
+    fn emits_the_allowlist_only_when_gated_on_it() {
+        let mut spec = spec();
+        let env = build_agent_env(
+            &record(),
+            &spec,
+            &ResolvedPrompt::default(),
+            "wss://r",
+            &[],
+            &DEFAULT_RUNTIME,
+        );
+        assert!(lookup(&env, "BUZZ_ACP_RESPOND_TO_ALLOWLIST").is_none());
+
+        spec.respond_to = RespondTo::Allowlist;
+        spec.respond_to_allowlist = vec!["c".repeat(64), "d".repeat(64)];
+        let env = build_agent_env(
+            &record(),
+            &spec,
+            &ResolvedPrompt::default(),
+            "wss://r",
+            &[],
+            &DEFAULT_RUNTIME,
+        );
+        assert_eq!(
+            lookup(&env, "BUZZ_ACP_RESPOND_TO_ALLOWLIST"),
+            Some(format!("{}{}{}", "c".repeat(64), ",", "d".repeat(64)))
+        );
+    }
+}
diff --git a/crates/buzz-spawner/src/lib.rs b/crates/buzz-spawner/src/lib.rs
new file mode 100644
index 0000000000..fb70690f61
--- /dev/null
+++ b/crates/buzz-spawner/src/lib.rs
@@ -0,0 +1,50 @@
+#![deny(unsafe_code)]
+#![warn(missing_docs)]
+//! `buzz-spawner` — reconciles Nostr agent specs into running agent containers.
+//!
+//! # What it is
+//!
+//! A standalone daemon that runs beside a Buzz relay and gives self-hosters
+//! first-party server-side agents. Without it, every agent in Buzz is spawned by
+//! the desktop app: agents die when the laptop sleeps, and mobile and web cannot
+//! create one at all.
+//!
+//! It is deliberately *not* part of `buzz-relay`. The relay's only subprocess
+//! today is `git`, and it goes out of its way to disable repo hooks; arbitrary
+//! container execution does not belong in a public-facing WebSocket server. This
+//! follows the existing sidecar-crate precedent (`buzz-pair-relay`,
+//! `buzz-push-gateway`).
+//!
+//! # How it works
+//!
+//! The daemon is an ordinary relay client with its own Nostr identity. It holds
+//! no database connection and no privileged relay access.
+//!
+//! ```text
+//! owner   ──kind:30178 spec───────────────────────────►  spawner
+//! spawner ──kind:24201 request (agent pubkey + nonce)─►  owner
+//! owner   ──kind:24201 response (NIP-OA auth tag)─────►  spawner
+//! spawner ──kind:30179 status: running────────────────►  owner
+//!                        │
+//!                        └──► Docker: one buzz-acp container per agent
+//! ```
+//!
+//! Desired state is [`buzz_sdk::spawner::SpawnerAgentSpec`] events; actual state
+//! is Docker containers labelled `com.buzz.agent`. [`reconcile::plan`] diffs the
+//! two as a pure function, and [`daemon::Daemon`] applies the result.
+//!
+//! # Why the handshake exists
+//!
+//! Each agent's secret key is minted on this host and never transmitted. But a
+//! NIP-OA auth tag binds one specific agent pubkey and is signed with the
+//! *owner's* secret key, so the spawner cannot self-attest and the owner cannot
+//! pre-authorize a pubkey that does not exist yet. See [`attestation`].
+
+pub mod attestation;
+pub mod config;
+pub mod container;
+pub mod daemon;
+pub mod env;
+pub mod reconcile;
+pub mod relay;
+pub mod store;
diff --git a/crates/buzz-spawner/src/main.rs b/crates/buzz-spawner/src/main.rs
new file mode 100644
index 0000000000..962222dd93
--- /dev/null
+++ b/crates/buzz-spawner/src/main.rs
@@ -0,0 +1,38 @@
+//! `buzz-spawner` binary entry point.
+
+use std::sync::Arc;
+
+use anyhow::Result;
+use buzz_spawner::{
+    config::Config,
+    container::{ContainerOps, DockerOps},
+    daemon::Daemon,
+};
+use tracing::info;
+
+#[tokio::main]
+async fn main() -> Result<()> {
+    tracing_subscriber::fmt()
+        .with_env_filter(
+            tracing_subscriber::EnvFilter::try_from_default_env()
+                .unwrap_or_else(|_| "buzz_spawner=info".into()),
+        )
+        .init();
+
+    let config = Config::from_env()?;
+    let containers: Arc<dyn ContainerOps> = Arc::new(DockerOps::connect()?);
+
+    let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
+    tokio::spawn(async move {
+        if tokio::signal::ctrl_c().await.is_ok() {
+            info!("received SIGINT");
+            let _ = shutdown_tx.send(true);
+        }
+    });
+
+    let mut daemon = Daemon::start(config, containers).await?;
+    // An initial pass before the first tick, so a restart re-adopts running
+    // containers and re-requests any attestation that expired while down.
+    daemon.reconcile().await?;
+    daemon.run(shutdown_rx).await
+}
diff --git a/crates/buzz-spawner/src/reconcile.rs b/crates/buzz-spawner/src/reconcile.rs
new file mode 100644
index 0000000000..bbf935def9
--- /dev/null
+++ b/crates/buzz-spawner/src/reconcile.rs
@@ -0,0 +1,727 @@
+//! Desired state (specs) versus actual state (containers).
+//!
+//! The diff is a pure function so it can be tested without Docker or a relay.
+//! Everything that performs I/O lives in [`crate::daemon`]; this module only
+//! decides *what* should happen.
+
+use std::collections::{HashMap, HashSet};
+
+use buzz_sdk::spawner::SpawnerAgentSpec;
+use sha2::{Digest, Sha256};
+
+use crate::{container::ManagedContainer, store::AgentRecord};
+
+/// A spec the spawner is responsible for, paired with its author.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DesiredAgent {
+    /// Spec slug (`d` tag).
+    pub slug: String,
+    /// Spec author pubkey, hex.
+    pub owner_pubkey: String,
+    /// The spec body.
+    pub spec: SpawnerAgentSpec,
+}
+
+impl DesiredAgent {
+    /// Stable content hash of the spec, used to detect drift.
+    ///
+    /// Hashes the serialized spec rather than the event id: an owner can
+    /// republish an identical spec (a NIP-33 replacement with a new timestamp
+    /// and id), and that must not restart a healthy agent.
+    pub fn spec_hash(&self) -> String {
+        let json = serde_json::to_string(&self.spec).unwrap_or_default();
+        let mut hasher = Sha256::new();
+        hasher.update(json.as_bytes());
+        hex::encode(hasher.finalize())
+    }
+}
+
+/// One unit of work the reconciler should perform.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Action {
+    /// Mint a keypair and open an attestation handshake.
+    Provision {
+        /// The spec to provision for.
+        desired: DesiredAgent,
+    },
+    /// Re-send an attestation request whose previous round timed out.
+    ReRequestAttestation {
+        /// Owner pubkey.
+        owner_pubkey: String,
+        /// Spec slug.
+        slug: String,
+    },
+    /// Create the container for an attested agent.
+    Start {
+        /// The spec to start.
+        desired: DesiredAgent,
+    },
+    /// Replace a container.
+    Restart {
+        /// The spec to restart against.
+        desired: DesiredAgent,
+        /// Container to remove first.
+        container_id: String,
+        /// True when the container died on its own rather than being replaced
+        /// because its spec changed.
+        ///
+        /// The two look identical at the Docker layer but mean opposite things.
+        /// A drifted container was healthy and is being deliberately replaced;
+        /// a crashed one failed and must count toward backoff. Without the
+        /// distinction a container that starts and immediately exits is
+        /// recreated on every reconcile pass forever, because creating it
+        /// always "succeeds".
+        crashed: bool,
+    },
+    /// Stop a container the spec disabled, keeping identity and state.
+    Stop {
+        /// Owner pubkey.
+        owner_pubkey: String,
+        /// Spec slug.
+        slug: String,
+        /// Container to remove.
+        container_id: String,
+    },
+    /// Tear an agent down entirely — its spec is gone.
+    Delete {
+        /// Owner pubkey.
+        owner_pubkey: String,
+        /// Spec slug.
+        slug: String,
+        /// Container to remove, if one exists.
+        container_id: Option<String>,
+    },
+    /// Remove a container with no corresponding record at all.
+    RemoveOrphan {
+        /// Container to remove.
+        container_id: String,
+    },
+}
+
+/// Inputs to a reconciliation pass.
+pub struct ReconcileInput<'a> {
+    /// Specs addressed to this spawner, from the relay.
+    pub desired: &'a [DesiredAgent],
+    /// What the spawner has minted so far.
+    pub records: &'a [AgentRecord],
+    /// Containers currently on the host carrying this spawner's label.
+    pub containers: &'a [ManagedContainer],
+    /// Current unix time.
+    pub now: i64,
+    /// Attestation timeout, seconds.
+    pub attestation_timeout_secs: i64,
+    /// Cap on concurrently running agents.
+    pub max_agents: usize,
+    /// Whether `desired` reflects the relay's full set of specs yet.
+    ///
+    /// False between startup and the specs subscription reaching EOSE. In that
+    /// window an absent spec means "not delivered yet", not "deleted", and the
+    /// two are indistinguishable from the desired-state map alone. Acting on
+    /// the wrong reading destroys an agent's container, volume, and secret key
+    /// on every restart — the agent comes back with a new identity and loses
+    /// its channel membership and attestation.
+    pub desired_hydrated: bool,
+}
+
+/// Backoff before retrying a failed start, capped so a permanently broken agent
+/// still gets an occasional retry without hammering the Docker daemon.
+pub fn backoff_secs(restart_count: u32) -> i64 {
+    const CAP: i64 = 600;
+    let exp = 15i64.saturating_mul(1 << restart_count.min(6));
+    exp.min(CAP)
+}
+
+/// Compute the actions needed to bring actual state in line with desired state.
+///
+/// Ordering matters: removals are emitted before creations so a pass that both
+/// deletes and provisions frees its agent-cap slot first.
+pub fn plan(input: ReconcileInput<'_>) -> Vec<Action> {
+    let records: HashMap<(&str, &str), &AgentRecord> = input
+        .records
+        .iter()
+        .map(|r| ((r.owner_pubkey.as_str(), r.slug.as_str()), r))
+        .collect();
+
+    let containers_by_agent: HashMap<&str, &ManagedContainer> = input
+        .containers
+        .iter()
+        .map(|c| (c.agent_pubkey.as_str(), c))
+        .collect();
+
+    let desired_keys: HashSet<(&str, &str)> = input
+        .desired
+        .iter()
+        .map(|d| (d.owner_pubkey.as_str(), d.slug.as_str()))
+        .collect();
+
+    let mut actions = Vec::new();
+
+    // Destructive actions are gated on hydration. Everything below this block
+    // is additive or idempotent and is safe to run against partial desired
+    // state; deletion is neither, and is irreversible.
+    if input.desired_hydrated {
+        // 1. Records whose spec disappeared — the owner deleted it.
+        for record in input.records {
+            if desired_keys.contains(&(record.owner_pubkey.as_str(), record.slug.as_str())) {
+                continue;
+            }
+            actions.push(Action::Delete {
+                owner_pubkey: record.owner_pubkey.clone(),
+                slug: record.slug.clone(),
+                container_id: containers_by_agent
+                    .get(record.agent_pubkey.as_str())
+                    .map(|c| c.id.clone()),
+            });
+        }
+
+        // 2. Containers with no record at all. These are unreachable: the
+        // spawner has no key for them, so it can neither manage nor speak for
+        // them.
+        let known_agents: HashSet<&str> = input
+            .records
+            .iter()
+            .map(|r| r.agent_pubkey.as_str())
+            .collect();
+        for container in input.containers {
+            if !known_agents.contains(container.agent_pubkey.as_str()) {
+                actions.push(Action::RemoveOrphan {
+                    container_id: container.id.clone(),
+                });
+            }
+        }
+    }
+
+    // A pass that deletes frees capacity, so count only the agents that survive.
+    let mut running_budget = input.max_agents.saturating_sub(
+        input
+            .records
+            .iter()
+            .filter(|r| {
+                desired_keys.contains(&(r.owner_pubkey.as_str(), r.slug.as_str()))
+                    && containers_by_agent.contains_key(r.agent_pubkey.as_str())
+            })
+            .count(),
+    );
+
+    for desired in input.desired {
+        let key = (desired.owner_pubkey.as_str(), desired.slug.as_str());
+        let Some(record) = records.get(&key) else {
+            // 3. Never seen — mint a key and open the handshake.
+            actions.push(Action::Provision {
+                desired: desired.clone(),
+            });
+            continue;
+        };
+
+        // A spec that names an identity different from the one on record means
+        // the agent was relocated here after this spawner had already minted
+        // one for the same slug. Spec drift alone would restart the *wrong*
+        // identity forever, so the minted stand-in has to be torn down and the
+        // named agent adopted in its place.
+        if desired
+            .spec
+            .agent_pubkey
+            .as_deref()
+            .is_some_and(|wanted| !wanted.eq_ignore_ascii_case(&record.agent_pubkey))
+        {
+            actions.push(Action::Delete {
+                owner_pubkey: desired.owner_pubkey.clone(),
+                slug: desired.slug.clone(),
+                container_id: containers_by_agent
+                    .get(record.agent_pubkey.as_str())
+                    .map(|c| c.id.clone()),
+            });
+            actions.push(Action::Provision {
+                desired: desired.clone(),
+            });
+            continue;
+        }
+
+        let container = containers_by_agent.get(record.agent_pubkey.as_str());
+
+        // 4. Disabled: stop the container but keep the identity, so re-enabling
+        // resumes the same agent rather than creating a stranger.
+        if !desired.spec.enabled {
+            if let Some(container) = container {
+                actions.push(Action::Stop {
+                    owner_pubkey: desired.owner_pubkey.clone(),
+                    slug: desired.slug.clone(),
+                    container_id: container.id.clone(),
+                });
+            }
+            continue;
+        }
+
+        // 5. Not yet attested — chase the handshake rather than starting.
+        if !record.is_attested() {
+            if crate::attestation::is_attestation_expired(
+                record,
+                input.now,
+                input.attestation_timeout_secs,
+            ) {
+                actions.push(Action::ReRequestAttestation {
+                    owner_pubkey: desired.owner_pubkey.clone(),
+                    slug: desired.slug.clone(),
+                });
+            }
+            continue;
+        }
+
+        let hash = desired.spec_hash();
+
+        match container {
+            // 6. Running with a stale spec — replace it.
+            Some(container) if record.spec_hash.as_deref() != Some(hash.as_str()) => {
+                actions.push(Action::Restart {
+                    desired: desired.clone(),
+                    container_id: container.id.clone(),
+                    crashed: false,
+                });
+            }
+            // 7. Present and current, but the container died out of band.
+            Some(container) if !container.running => {
+                if !in_backoff(record, input.now) {
+                    actions.push(Action::Restart {
+                        desired: desired.clone(),
+                        container_id: container.id.clone(),
+                        crashed: true,
+                    });
+                }
+            }
+            // 8. Healthy. Nothing to do.
+            Some(_) => {}
+            // 9. Attested but not running.
+            None => {
+                if in_backoff(record, input.now) {
+                    continue;
+                }
+                if running_budget == 0 {
+                    continue;
+                }
+                running_budget -= 1;
+                actions.push(Action::Start {
+                    desired: desired.clone(),
+                });
+            }
+        }
+    }
+
+    actions
+}
+
+fn in_backoff(record: &AgentRecord, now: i64) -> bool {
+    match record.last_failure_at {
+        Some(failed_at) => now.saturating_sub(failed_at) < backoff_secs(record.restart_count),
+        None => false,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use buzz_sdk::spawner::RespondTo;
+
+    fn spec(enabled: bool, parallelism: u32) -> SpawnerAgentSpec {
+        SpawnerAgentSpec {
+            name: "Fizz".into(),
+            agent_pubkey: None,
+            persona_id: Some("builtin:fizz".into()),
+            system_prompt: None,
+            model: None,
+            provider: None,
+            parallelism,
+            respond_to: RespondTo::Anyone,
+            respond_to_allowlist: vec![],
+            resources: None,
+            enabled,
+        }
+    }
+
+    fn desired(slug: &str, enabled: bool, parallelism: u32) -> DesiredAgent {
+        DesiredAgent {
+            slug: slug.into(),
+            owner_pubkey: "b".repeat(64),
+            spec: spec(enabled, parallelism),
+        }
+    }
+
+    fn record(slug: &str, agent: &str, attested: bool, hash: Option<&str>) -> AgentRecord {
+        AgentRecord {
+            slug: slug.into(),
+            owner_pubkey: "b".repeat(64),
+            agent_pubkey: agent.into(),
+            private_key_nsec: "nsec1x".into(),
+            auth_tag: attested.then(|| r#"["auth","o","","s"]"#.to_string()),
+            pending_nonce: (!attested).then(|| "n".repeat(64)),
+            attestation_sent_at: (!attested).then_some(1_000),
+            spec_hash: hash.map(str::to_string),
+            prompt: None,
+            restart_count: 0,
+            last_failure_at: None,
+        }
+    }
+
+    fn container(agent: &str, running: bool) -> ManagedContainer {
+        ManagedContainer {
+            id: format!("ctr-{agent}"),
+            name: format!("buzz-agent-{agent}"),
+            agent_pubkey: agent.into(),
+            slug: "fizz".into(),
+            running,
+        }
+    }
+
+    fn input<'a>(
+        desired: &'a [DesiredAgent],
+        records: &'a [AgentRecord],
+        containers: &'a [ManagedContainer],
+    ) -> ReconcileInput<'a> {
+        ReconcileInput {
+            desired,
+            records,
+            containers,
+            now: 1_100,
+            attestation_timeout_secs: 600,
+            max_agents: 16,
+            desired_hydrated: true,
+        }
+    }
+
+    #[test]
+    fn provisions_a_spec_it_has_never_seen() {
+        let d = vec![desired("fizz", true, 1)];
+        let actions = plan(input(&d, &[], &[]));
+        assert!(matches!(actions.as_slice(), [Action::Provision { .. }]));
+    }
+
+    #[test]
+    fn starts_an_attested_agent_with_no_container() {
+        let d = vec![desired("fizz", true, 1)];
+        let hash = d[0].spec_hash();
+        let r = vec![record("fizz", "agent1", true, Some(&hash))];
+        let actions = plan(input(&d, &r, &[]));
+        assert!(matches!(actions.as_slice(), [Action::Start { .. }]));
+    }
+
+    #[test]
+    fn does_nothing_when_running_and_current() {
+        let d = vec![desired("fizz", true, 1)];
+        let hash = d[0].spec_hash();
+        let r = vec![record("fizz", "agent1", true, Some(&hash))];
+        let c = vec![container("agent1", true)];
+        assert!(plan(input(&d, &r, &c)).is_empty());
+    }
+
+    #[test]
+    fn republishing_an_identical_spec_does_not_restart() {
+        // NIP-33 replacement gives a new event id and timestamp for identical
+        // content. Hashing content rather than the event keeps the agent up.
+        let d = [desired("fizz", true, 1)];
+        let hash = d[0].spec_hash();
+        let d2 = vec![desired("fizz", true, 1)];
+        assert_eq!(hash, d2[0].spec_hash());
+
+        let r = vec![record("fizz", "agent1", true, Some(&hash))];
+        let c = vec![container("agent1", true)];
+        assert!(plan(input(&d2, &r, &c)).is_empty());
+    }
+
+    #[test]
+    fn restarts_on_spec_drift() {
+        let old = desired("fizz", true, 1);
+        let r = vec![record("fizz", "agent1", true, Some(&old.spec_hash()))];
+        let c = vec![container("agent1", true)];
+        let d = vec![desired("fizz", true, 4)];
+        assert!(matches!(
+            plan(input(&d, &r, &c)).as_slice(),
+            [Action::Restart { .. }]
+        ));
+    }
+
+    #[test]
+    fn restarts_a_container_that_died_out_of_band_and_marks_it_crashed() {
+        let d = vec![desired("fizz", true, 1)];
+        let hash = d[0].spec_hash();
+        let r = vec![record("fizz", "agent1", true, Some(&hash))];
+        let c = vec![container("agent1", false)];
+        assert!(matches!(
+            plan(input(&d, &r, &c)).as_slice(),
+            [Action::Restart { crashed: true, .. }]
+        ));
+    }
+
+    #[test]
+    fn a_drift_restart_is_not_a_crash() {
+        // A healthy container replaced because its spec changed must not count
+        // toward backoff — otherwise editing an agent repeatedly would throttle
+        // a perfectly working one.
+        let old = desired("fizz", true, 1);
+        let r = vec![record("fizz", "agent1", true, Some(&old.spec_hash()))];
+        let c = vec![container("agent1", true)];
+        let d = vec![desired("fizz", true, 4)];
+        assert!(matches!(
+            plan(input(&d, &r, &c)).as_slice(),
+            [Action::Restart { crashed: false, .. }]
+        ));
+    }
+
+    #[test]
+    fn a_crash_looping_container_is_throttled() {
+        // The bug this guards: a container that starts then immediately exits
+        // is recreated on every pass, because container *creation* keeps
+        // succeeding. Only counting crashes makes the backoff bite.
+        let d = vec![desired("fizz", true, 1)];
+        let hash = d[0].spec_hash();
+        let mut rec = record("fizz", "agent1", true, Some(&hash));
+        rec.restart_count = 4;
+        rec.last_failure_at = Some(1_090);
+        let r = vec![rec];
+        let c = vec![container("agent1", false)];
+
+        // 15 * 2^4 = 240s of backoff; only 10s have elapsed.
+        assert!(plan(input(&d, &r, &c)).is_empty());
+    }
+
+    #[test]
+    fn respects_backoff_after_repeated_failures() {
+        let d = vec![desired("fizz", true, 1)];
+        let hash = d[0].spec_hash();
+        let mut rec = record("fizz", "agent1", true, Some(&hash));
+        rec.restart_count = 3;
+        rec.last_failure_at = Some(1_090);
+        let r = vec![rec];
+
+        // 15 * 2^3 = 120s of backoff; only 10s have elapsed.
+        assert!(plan(input(&d, &r, &[])).is_empty());
+
+        let mut later = input(&d, &r, &[]);
+        later.now = 1_090 + 121;
+        assert!(matches!(plan(later).as_slice(), [Action::Start { .. }]));
+    }
+
+    #[test]
+    fn backoff_is_capped() {
+        assert_eq!(backoff_secs(0), 15);
+        assert_eq!(backoff_secs(3), 120);
+        assert_eq!(backoff_secs(20), 600, "must not grow without bound");
+    }
+
+    #[test]
+    fn replaces_a_minted_identity_when_the_spec_names_a_different_agent() {
+        // The relocation case: the spawner already minted an identity for this
+        // slug, then the owner published a spec naming an existing agent to
+        // move here. Treating that as ordinary drift would restart the minted
+        // stand-in forever and the real agent would never arrive.
+        let mut d = desired("fizz", true, 1);
+        d.spec.agent_pubkey = Some("f".repeat(64));
+        let r = vec![record("fizz", "agent-minted", true, Some(&d.spec_hash()))];
+        let c = vec![container("agent-minted", true)];
+
+        let actions = plan(input(&[d], &r, &c));
+        assert!(actions.iter().any(|a| matches!(a, Action::Delete { .. })));
+        assert!(actions
+            .iter()
+            .any(|a| matches!(a, Action::Provision { .. })));
+        // The wrong identity must not simply be restarted.
+        assert!(!actions.iter().any(|a| matches!(a, Action::Restart { .. })));
+    }
+
+    #[test]
+    fn relocation_converges_instead_of_looping() {
+        // Regression: the identity-mismatch rule tears down a minted stand-in
+        // and re-provisions. If provisioning mints AGAIN rather than adopting
+        // the named pubkey, the next pass sees a mismatch again and the spawner
+        // churns containers forever — 29 mints in one run before this was
+        // caught. Model the adopt step and assert the second pass is quiet.
+        let mut d = desired("fizz", true, 1);
+        let wanted = "f".repeat(64);
+        d.spec.agent_pubkey = Some(wanted.clone());
+
+        // Pass 1: a minted identity is on record, so it must be replaced.
+        let minted = vec![record("fizz", "agent-minted", true, Some(&d.spec_hash()))];
+        let first = plan(input(std::slice::from_ref(&d), &minted, &[]));
+        assert!(first.iter().any(|a| matches!(a, Action::Delete { .. })));
+        assert!(first.iter().any(|a| matches!(a, Action::Provision { .. })));
+
+        // Pass 2: provisioning adopted the named pubkey, as `provision` does
+        // for a spec carrying `agent_pubkey`. Nothing further should happen
+        // beyond starting it — crucially, no second Delete/Provision cycle.
+        let adopted = vec![record("fizz", &wanted, true, Some(&d.spec_hash()))];
+        let second = plan(input(&[d], &adopted, &[]));
+        assert!(
+            !second.iter().any(|a| matches!(a, Action::Delete { .. })),
+            "an adopted identity must not be torn down again"
+        );
+        assert!(
+            !second.iter().any(|a| matches!(a, Action::Provision { .. })),
+            "an adopted identity must not be re-provisioned"
+        );
+        assert!(matches!(second.as_slice(), [Action::Start { .. }]));
+    }
+
+    #[test]
+    fn relocation_matching_is_case_insensitive() {
+        // A pubkey that differs only in case is the same identity; treating it
+        // as a mismatch would restart the mint/delete cycle.
+        let mut d = desired("fizz", true, 1);
+        d.spec.agent_pubkey = Some("A".repeat(64));
+        let hash = d.spec_hash();
+        let r = vec![record("fizz", &"a".repeat(64), true, Some(&hash))];
+        let c = vec![container(&"a".repeat(64), true)];
+        assert!(plan(input(&[d], &r, &c)).is_empty());
+    }
+
+    #[test]
+    fn leaves_a_matching_relocated_identity_alone() {
+        // Same pubkey on spec and record: this is the steady state after a
+        // successful relocation, and must not churn.
+        let mut d = desired("fizz", true, 1);
+        d.spec.agent_pubkey = Some("a".repeat(64));
+        let hash = d.spec_hash();
+        let r = vec![record("fizz", &"a".repeat(64), true, Some(&hash))];
+        let c = vec![container(&"a".repeat(64), true)];
+        assert!(plan(input(&[d], &r, &c)).is_empty());
+    }
+
+    #[test]
+    fn stops_a_disabled_agent_without_deleting_its_identity() {
+        let d = vec![desired("fizz", false, 1)];
+        let hash = d[0].spec_hash();
+        let r = vec![record("fizz", "agent1", true, Some(&hash))];
+        let c = vec![container("agent1", true)];
+        let actions = plan(input(&d, &r, &c));
+        assert!(matches!(actions.as_slice(), [Action::Stop { .. }]));
+        // Crucially not a Delete — re-enabling must resume the same agent.
+        assert!(!actions.iter().any(|a| matches!(a, Action::Delete { .. })));
+    }
+
+    #[test]
+    fn deletes_when_the_spec_disappears() {
+        let hash = desired("fizz", true, 1).spec_hash();
+        let r = vec![record("fizz", "agent1", true, Some(&hash))];
+        let c = vec![container("agent1", true)];
+        let actions = plan(input(&[], &r, &c));
+        assert_eq!(
+            actions,
+            [Action::Delete {
+                owner_pubkey: "b".repeat(64),
+                slug: "fizz".into(),
+                container_id: Some("ctr-agent1".into()),
+            }]
+        );
+    }
+
+    #[test]
+    fn never_deletes_before_desired_state_is_hydrated() {
+        // The restart bug: at boot the relay has not replayed specs yet, so
+        // `desired` is empty. Reading that as "every spec was deleted" destroys
+        // each agent's container, volume, and secret key, and the agent comes
+        // back as a brand-new pubkey that has lost its attestation and channel
+        // membership. An absent spec before EOSE means "unknown", not "gone".
+        let hash = desired("fizz", true, 1).spec_hash();
+        let r = vec![record("fizz", "agent1", true, Some(&hash))];
+        let c = vec![container("agent1", true)];
+
+        let mut booting = input(&[], &r, &c);
+        booting.desired_hydrated = false;
+        assert!(
+            plan(booting).is_empty(),
+            "a pre-hydration pass must not delete anything"
+        );
+
+        // Once the relay confirms the spec really is gone, deletion proceeds.
+        let mut hydrated = input(&[], &r, &c);
+        hydrated.desired_hydrated = true;
+        assert!(plan(hydrated)
+            .iter()
+            .any(|a| matches!(a, Action::Delete { .. })));
+    }
+
+    #[test]
+    fn still_starts_known_agents_before_hydration() {
+        // Gating deletion must not stall the additive path: an agent whose spec
+        // already arrived should start without waiting for EOSE.
+        let d = vec![desired("fizz", true, 1)];
+        let hash = d[0].spec_hash();
+        let r = vec![record("fizz", "agent1", true, Some(&hash))];
+
+        let mut booting = input(&d, &r, &[]);
+        booting.desired_hydrated = false;
+        assert!(matches!(plan(booting).as_slice(), [Action::Start { .. }]));
+    }
+
+    #[test]
+    fn never_reaps_orphan_containers_before_hydration() {
+        // Same reasoning for containers: at boot the store may still be loading
+        // and a running agent would look parentless.
+        let c = vec![container("stranger", true)];
+        let mut booting = input(&[], &[], &c);
+        booting.desired_hydrated = false;
+        assert!(plan(booting).is_empty());
+    }
+
+    #[test]
+    fn removes_containers_with_no_record() {
+        // No record means no key: the spawner cannot manage or speak for it.
+        let c = vec![container("stranger", true)];
+        assert!(matches!(
+            plan(input(&[], &[], &c)).as_slice(),
+            [Action::RemoveOrphan { .. }]
+        ));
+    }
+
+    #[test]
+    fn chases_a_timed_out_attestation_instead_of_starting() {
+        let d = vec![desired("fizz", true, 1)];
+        let r = vec![record("fizz", "agent1", false, None)];
+
+        // Still within the window: wait quietly.
+        assert!(plan(input(&d, &r, &[])).is_empty());
+
+        let mut late = input(&d, &r, &[]);
+        late.now = 1_000 + 601;
+        assert!(matches!(
+            plan(late).as_slice(),
+            [Action::ReRequestAttestation { .. }]
+        ));
+    }
+
+    #[test]
+    fn honors_the_agent_cap() {
+        let d: Vec<_> = (0..5)
+            .map(|i| desired(&format!("fizz{i}"), true, 1))
+            .collect();
+        let r: Vec<_> = d
+            .iter()
+            .enumerate()
+            .map(|(i, x)| record(&x.slug, &format!("agent{i}"), true, Some(&x.spec_hash())))
+            .collect();
+
+        let mut capped = input(&d, &r, &[]);
+        capped.max_agents = 2;
+        let starts = plan(capped)
+            .into_iter()
+            .filter(|a| matches!(a, Action::Start { .. }))
+            .count();
+        assert_eq!(starts, 2);
+    }
+
+    #[test]
+    fn deletions_free_capacity_in_the_same_pass() {
+        // One agent is going away and one is waiting to start, with room for
+        // exactly one. The pass must not stall on the departing agent's slot.
+        let d = vec![desired("new", true, 1)];
+        let r = vec![
+            record("old", "agent-old", true, Some("stale")),
+            record("new", "agent-new", true, Some(&d[0].spec_hash())),
+        ];
+        let c = vec![container("agent-old", true)];
+
+        let mut capped = input(&d, &r, &c);
+        capped.max_agents = 1;
+        let actions = plan(capped);
+
+        assert!(actions.iter().any(|a| matches!(a, Action::Delete { .. })));
+        assert!(actions.iter().any(|a| matches!(a, Action::Start { .. })));
+    }
+}
diff --git a/crates/buzz-spawner/src/relay.rs b/crates/buzz-spawner/src/relay.rs
new file mode 100644
index 0000000000..39bd877629
--- /dev/null
+++ b/crates/buzz-spawner/src/relay.rs
@@ -0,0 +1,360 @@
+//! Relay client: subscriptions, publishing, and NIP-44 frame handling.
+//!
+//! The spawner is an ordinary relay client. It authenticates over NIP-42 with
+//! its own key, subscribes for the specs addressed to it and the attestation
+//! frames sent to it, and publishes status back. It holds no database
+//! connection and no privileged relay access.
+
+use std::{collections::HashMap, time::Duration};
+
+use anyhow::{bail, Context, Result};
+use buzz_core::kind::{KIND_PERSONA, KIND_SPAWNER_AGENT_SPEC, KIND_SPAWNER_ATTESTATION};
+use buzz_sdk::spawner::{
+    build_spawner_agent_status, build_spawner_announcement, build_spawner_attestation,
+    spec_from_event, spec_slug_from_event, AttestationFrame, SpawnerAgentStatus,
+    SpawnerAnnouncement, SPAWNER_TAG,
+};
+use buzz_ws_client::{connection::NostrWsConnection, message::RelayMessage};
+use nostr::{Event, Keys, PublicKey};
+use serde_json::json;
+use tracing::{debug, warn};
+
+use crate::reconcile::DesiredAgent;
+
+/// Subscription id for agent specs addressed to this spawner.
+const SUB_SPECS: &str = "spawner-specs";
+
+/// Subscription id for attestation frames addressed to this spawner.
+const SUB_ATTESTATION: &str = "spawner-attestation";
+
+/// How long to wait for a relay frame before yielding to the reconcile timer.
+const RECV_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// How long to wait for a one-shot query to reach EOSE.
+const QUERY_TIMEOUT: Duration = Duration::from_secs(15);
+
+/// Something the daemon needs to act on.
+pub enum Inbound {
+    /// A spec addressed to this spawner arrived or changed.
+    Spec {
+        /// Spec author.
+        owner_pubkey: String,
+        /// The parsed desired agent.
+        desired: DesiredAgent,
+        /// Event timestamp, so an older revision cannot overwrite a newer one.
+        created_at: u64,
+    },
+    /// A spec was deleted (NIP-09 tombstone or an emptied replacement).
+    SpecDeleted {
+        /// Spec author.
+        owner_pubkey: String,
+        /// Spec slug.
+        slug: String,
+        /// Event timestamp, so a stale tombstone cannot delete a live spec.
+        created_at: u64,
+    },
+    /// An attestation frame arrived, already decrypted.
+    Attestation {
+        /// Verified event author.
+        sender: PublicKey,
+        /// The decrypted frame.
+        frame: AttestationFrame,
+    },
+    /// The relay finished replaying stored specs.
+    ///
+    /// Until this arrives the daemon's desired state is *unknown*, not empty —
+    /// a distinction that matters enormously, because acting on an absent spec
+    /// means destroying an agent's container, volume, and secret key.
+    SpecsHydrated,
+    /// Nothing arrived before the receive timeout.
+    Idle,
+}
+
+/// A relay client scoped to spawner duties.
+pub struct SpawnerRelay {
+    conn: NostrWsConnection,
+    keys: Keys,
+    relay_url: String,
+}
+
+impl SpawnerRelay {
+    /// Connect, authenticate, and install both standing subscriptions.
+    pub async fn connect(relay_url: &str, keys: &Keys) -> Result<Self> {
+        // No NIP-OA tag: the spawner authenticates as itself, an ordinary relay
+        // member. It is not an agent and has no owner.
+        let conn = NostrWsConnection::connect_authenticated(relay_url, keys, None)
+            .await
+            .with_context(|| format!("failed to authenticate to {relay_url}"))?;
+
+        let mut relay = Self {
+            conn,
+            keys: keys.clone(),
+            relay_url: relay_url.to_string(),
+        };
+        relay.subscribe_all().await?;
+        Ok(relay)
+    }
+
+    /// Reconnect after a transport failure, restoring both subscriptions.
+    pub async fn reconnect(&mut self) -> Result<()> {
+        self.conn = NostrWsConnection::connect_authenticated(&self.relay_url, &self.keys, None)
+            .await
+            .with_context(|| format!("failed to reconnect to {}", self.relay_url))?;
+        self.subscribe_all().await
+    }
+
+    async fn subscribe_all(&mut self) -> Result<()> {
+        let me = self.keys.public_key().to_hex();
+
+        // Specs name their target spawner, so this filter admits only work
+        // meant for this daemon even when several share a relay.
+        self.conn
+            .send_raw(&json!([
+                "REQ",
+                SUB_SPECS,
+                {
+                    "kinds": [KIND_SPAWNER_AGENT_SPEC],
+                    format!("#{SPAWNER_TAG}"): [me],
+                }
+            ]))
+            .await?;
+
+        self.conn
+            .send_raw(&json!([
+                "REQ",
+                SUB_ATTESTATION,
+                {
+                    "kinds": [KIND_SPAWNER_ATTESTATION],
+                    "#p": [me],
+                }
+            ]))
+            .await?;
+
+        Ok(())
+    }
+
+    /// Wait for the next actionable relay frame.
+    pub async fn next(&mut self) -> Result<Inbound> {
+        let msg = match self.conn.next_event(RECV_TIMEOUT).await {
+            Ok(msg) => msg,
+            // A timeout is the common case, not a failure: it just means no
+            // event arrived within the window, so the daemon can run its
+            // periodic reconcile and come back.
+            Err(buzz_ws_client::error::WsClientError::Timeout) => return Ok(Inbound::Idle),
+            Err(e) => return Err(e.into()),
+        };
+
+        match msg {
+            RelayMessage::Event {
+                subscription_id,
+                event,
+            } => self.classify(&subscription_id, *event),
+            RelayMessage::Eose { subscription_id } if subscription_id == SUB_SPECS => {
+                Ok(Inbound::SpecsHydrated)
+            }
+            RelayMessage::Closed {
+                subscription_id,
+                message,
+            } => bail!("relay closed subscription {subscription_id}: {message}"),
+            _ => Ok(Inbound::Idle),
+        }
+    }
+
+    fn classify(&self, subscription_id: &str, event: Event) -> Result<Inbound> {
+        if subscription_id == SUB_ATTESTATION {
+            let frame = self.decrypt_frame(&event)?;
+            return Ok(Inbound::Attestation {
+                sender: event.pubkey,
+                frame,
+            });
+        }
+
+        let owner_pubkey = event.pubkey.to_hex();
+        let Some(slug) = spec_slug_from_event(&event) else {
+            // A spec without a `d` tag is unaddressable; there is nothing to
+            // reconcile it against.
+            debug!("dropping spec event {} with no d tag", event.id);
+            return Ok(Inbound::Idle);
+        };
+
+        // A NIP-33 replacement with empty content is the tombstone convention
+        // for parameterized-replaceable events, since a delete leaves nothing
+        // to fan out.
+        if event.content.trim().is_empty() {
+            return Ok(Inbound::SpecDeleted {
+                owner_pubkey,
+                slug,
+                created_at: event.created_at.as_secs(),
+            });
+        }
+
+        match spec_from_event(&event) {
+            Ok(spec) => Ok(Inbound::Spec {
+                owner_pubkey: owner_pubkey.clone(),
+                desired: DesiredAgent {
+                    slug,
+                    owner_pubkey,
+                    spec,
+                },
+                created_at: event.created_at.as_secs(),
+            }),
+            Err(e) => {
+                // An invalid spec is the owner's bug, not ours. Log it and keep
+                // serving every other agent rather than failing the pass.
+                warn!("ignoring invalid spec {owner_pubkey}/{slug}: {e}");
+                Ok(Inbound::Idle)
+            }
+        }
+    }
+
+    fn decrypt_frame(&self, event: &Event) -> Result<AttestationFrame> {
+        let plaintext = nostr::nips::nip44::decrypt(
+            self.keys.secret_key(),
+            &event.pubkey,
+            event.content.as_str(),
+        )
+        .context("failed to decrypt attestation frame")?;
+
+        let frame: AttestationFrame =
+            serde_json::from_str(&plaintext).context("failed to parse attestation frame")?;
+        frame.validate().context("malformed attestation frame")?;
+        Ok(frame)
+    }
+
+    /// Send an attestation frame, NIP-44 encrypted to `recipient`.
+    pub async fn send_attestation(
+        &mut self,
+        recipient: &PublicKey,
+        frame: &AttestationFrame,
+    ) -> Result<()> {
+        let plaintext =
+            serde_json::to_string(frame).context("failed to serialize attestation frame")?;
+        let ciphertext = nostr::nips::nip44::encrypt(
+            self.keys.secret_key(),
+            recipient,
+            plaintext,
+            nostr::nips::nip44::Version::V2,
+        )
+        .context("failed to encrypt attestation frame")?;
+
+        let event = build_spawner_attestation(&recipient.to_hex(), &ciphertext)?
+            .sign_with_keys(&self.keys)?;
+        let ok = self.conn.send_event(event).await?;
+        if !ok.accepted {
+            bail!("relay rejected attestation frame: {}", ok.message);
+        }
+        Ok(())
+    }
+
+    /// Publish this spawner's announcement so owners can discover it.
+    ///
+    /// Replaceable and keyed by `(pubkey, kind)`, so republishing on every
+    /// reconcile keeps the advertised capacity roughly current without
+    /// accumulating events.
+    pub async fn publish_announcement(&mut self, announcement: &SpawnerAnnouncement) -> Result<()> {
+        let event = build_spawner_announcement(announcement)?.sign_with_keys(&self.keys)?;
+        let ok = self.conn.send_event(event).await?;
+        if !ok.accepted {
+            bail!("relay rejected announcement: {}", ok.message);
+        }
+        Ok(())
+    }
+
+    /// Publish a status event for one agent.
+    pub async fn publish_status(
+        &mut self,
+        slug: &str,
+        owner_pubkey: &str,
+        status: &SpawnerAgentStatus,
+    ) -> Result<()> {
+        let event =
+            build_spawner_agent_status(slug, owner_pubkey, status)?.sign_with_keys(&self.keys)?;
+        let ok = self.conn.send_event(event).await?;
+        if !ok.accepted {
+            bail!("relay rejected status event: {}", ok.message);
+        }
+        Ok(())
+    }
+
+    /// Tombstone an agent's status so clients stop showing it.
+    ///
+    /// Kind 30179 is replaceable, so a deleted agent's last status — often
+    /// `pending_attestation` — would otherwise persist forever and every client
+    /// would keep rendering a row for an agent that no longer exists. An
+    /// emptied replacement is the tombstone convention for a
+    /// parameterized-replaceable kind; a kind:5 deletion leaves nothing to fan
+    /// out, so subscribers would never learn.
+    ///
+    /// Built directly rather than through the SDK builder because that
+    /// validates a status body, which a tombstone deliberately has none of.
+    pub async fn tombstone_status(&mut self, slug: &str, owner_pubkey: &str) -> Result<()> {
+        let event = nostr::EventBuilder::new(
+            nostr::Kind::Custom(buzz_core::kind::KIND_SPAWNER_AGENT_STATUS as u16),
+            "",
+        )
+        .tags(vec![
+            nostr::Tag::parse(["d", slug])?,
+            nostr::Tag::parse(["p", owner_pubkey])?,
+        ])
+        .sign_with_keys(&self.keys)?;
+        let ok = self.conn.send_event(event).await?;
+        if !ok.accepted {
+            bail!("relay rejected status tombstone: {}", ok.message);
+        }
+        Ok(())
+    }
+
+    /// Fetch the personas authored by `owner`, keyed by `d` tag.
+    ///
+    /// The spawner reads unshared personas through the NIP-OA delegation the
+    /// relay applies to attested readers, so an owner does not have to publish
+    /// their system prompts `["shared","true"]` to the whole community just to
+    /// run an agent.
+    pub async fn fetch_personas(&mut self, owner: &PublicKey) -> Result<HashMap<String, Event>> {
+        let sub_id = format!("personas-{}", &owner.to_hex()[..8]);
+        self.conn
+            .send_raw(&json!([
+                "REQ",
+                sub_id,
+                { "kinds": [KIND_PERSONA], "authors": [owner.to_hex()] }
+            ]))
+            .await?;
+
+        let mut personas = HashMap::new();
+        let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
+        loop {
+            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
+            if remaining.is_zero() {
+                warn!("persona query for {} timed out", owner.to_hex());
+                break;
+            }
+            match self.conn.next_event(remaining).await {
+                Ok(RelayMessage::Event {
+                    subscription_id,
+                    event,
+                }) if subscription_id == sub_id => {
+                    if let Some(d) = spec_slug_from_event(&event) {
+                        personas.insert(d, *event);
+                    }
+                }
+                Ok(RelayMessage::Eose { subscription_id }) if subscription_id == sub_id => break,
+                Ok(RelayMessage::Closed {
+                    subscription_id,
+                    message,
+                }) if subscription_id == sub_id => {
+                    bail!("relay closed persona query: {message}");
+                }
+                // Frames for the standing subscriptions can interleave with
+                // this one-shot query. Dropping them is safe: specs are
+                // replaceable and re-delivered on reconnect, and the next
+                // reconcile pass re-reads state anyway.
+                Ok(_) => continue,
+                Err(buzz_ws_client::error::WsClientError::Timeout) => break,
+                Err(e) => return Err(e.into()),
+            }
+        }
+
+        let _ = self.conn.send_raw(&json!(["CLOSE", sub_id])).await;
+        Ok(personas)
+    }
+}
diff --git a/crates/buzz-spawner/src/store.rs b/crates/buzz-spawner/src/store.rs
new file mode 100644
index 0000000000..8a41cb0045
--- /dev/null
+++ b/crates/buzz-spawner/src/store.rs
@@ -0,0 +1,282 @@
+//! On-disk state for the spawner.
+//!
+//! Holds the one thing that cannot be reconstructed from the relay: each
+//! agent's secret key, minted here and never transmitted. Losing this file
+//! orphans every running agent — their pubkeys stay in the owner's channels and
+//! in relay membership, but nothing can sign as them again. It therefore belongs
+//! on a persistent volume, and is written mode 0600 with an atomic
+//! write-then-rename so a crash mid-write cannot truncate it.
+
+use std::{
+    collections::HashMap,
+    path::{Path, PathBuf},
+};
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+
+/// Everything the spawner knows about one agent it manages.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentRecord {
+    /// The spec slug this agent reconciles, unique per owner.
+    pub slug: String,
+    /// Owner pubkey, hex — the author of the spec.
+    pub owner_pubkey: String,
+    /// Agent pubkey, hex.
+    pub agent_pubkey: String,
+    /// Agent secret key, bech32 nsec. Never leaves this host.
+    pub private_key_nsec: String,
+    /// NIP-OA auth tag, as a JSON array string. Absent until the owner attests.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub auth_tag: Option<String>,
+    /// Nonce of the attestation round currently in flight, if any.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub pending_nonce: Option<String>,
+    /// Unix seconds when the pending attestation request was sent.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub attestation_sent_at: Option<i64>,
+    /// Hash of the spec content the running container was created from, so
+    /// drift can be detected without re-reading the container's env.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub spec_hash: Option<String>,
+    /// Prompt material the owner delivered over the encrypted handshake.
+    ///
+    /// Present when the spawner cannot read the referenced kind:30175 persona —
+    /// which is the normal case, since personas are author-only unless shared
+    /// and the spawner is not the author. Held here rather than fetched because
+    /// there is nowhere to fetch it from.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub prompt: Option<buzz_sdk::spawner::PromptMaterial>,
+    /// Consecutive failed start attempts, driving backoff.
+    #[serde(default)]
+    pub restart_count: u32,
+    /// Unix seconds of the last failed start, driving backoff.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub last_failure_at: Option<i64>,
+}
+
+impl AgentRecord {
+    /// Whether this agent can be started.
+    ///
+    /// Needs both halves: an owner attestation, and a secret key. A relocated
+    /// agent is recorded from its spec before its key arrives, so the key check
+    /// is not redundant — without it the spawner would launch a container with
+    /// an empty `BUZZ_PRIVATE_KEY` and the harness would fail obscurely.
+    pub fn is_attested(&self) -> bool {
+        self.auth_tag.is_some() && !self.private_key_nsec.is_empty()
+    }
+
+    /// The container name for this agent.
+    ///
+    /// Keyed on the owner pubkey prefix as well as the slug: slugs are chosen by
+    /// clients and are only unique *per owner*, so two owners both naming an
+    /// agent `fizz` must not collide into one container on a shared host.
+    pub fn container_name(&self) -> String {
+        format!(
+            "buzz-agent-{}-{}",
+            &self.owner_pubkey[..12.min(self.owner_pubkey.len())],
+            self.slug
+        )
+    }
+}
+
+/// The serialized state file.
+#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
+struct StateFile {
+    /// Keyed by `"<owner_pubkey>/<slug>"`.
+    #[serde(default)]
+    agents: HashMap<String, AgentRecord>,
+}
+
+/// Persistent store over a single JSON file.
+pub struct Store {
+    path: PathBuf,
+    state: StateFile,
+}
+
+/// Compose the map key for an agent.
+pub fn agent_key(owner_pubkey: &str, slug: &str) -> String {
+    format!("{owner_pubkey}/{slug}")
+}
+
+impl Store {
+    /// Open the store at `state_dir/agents.json`, creating the directory and an
+    /// empty state file if they do not exist.
+    pub fn open(state_dir: &Path) -> Result<Self> {
+        std::fs::create_dir_all(state_dir)
+            .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
+        let path = state_dir.join("agents.json");
+
+        let state = match std::fs::read_to_string(&path) {
+            Ok(raw) => serde_json::from_str(&raw)
+                .with_context(|| format!("failed to parse state file {}", path.display()))?,
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => StateFile::default(),
+            Err(e) => return Err(e).with_context(|| format!("failed to read {}", path.display())),
+        };
+
+        Ok(Self { path, state })
+    }
+
+    /// Every agent the spawner is managing.
+    pub fn agents(&self) -> impl Iterator<Item = &AgentRecord> {
+        self.state.agents.values()
+    }
+
+    /// Look up one agent.
+    pub fn get(&self, owner_pubkey: &str, slug: &str) -> Option<&AgentRecord> {
+        self.state.agents.get(&agent_key(owner_pubkey, slug))
+    }
+
+    /// Find the agent awaiting attestation for `agent_pubkey`, if any.
+    ///
+    /// The handshake response identifies itself by agent pubkey rather than by
+    /// slug, because that is what the owner actually signed over.
+    pub fn find_by_agent_pubkey(&self, agent_pubkey: &str) -> Option<&AgentRecord> {
+        self.state
+            .agents
+            .values()
+            .find(|r| r.agent_pubkey == agent_pubkey)
+    }
+
+    /// Insert or replace an agent and persist.
+    pub fn put(&mut self, record: AgentRecord) -> Result<()> {
+        let key = agent_key(&record.owner_pubkey, &record.slug);
+        self.state.agents.insert(key, record);
+        self.flush()
+    }
+
+    /// Mutate an agent in place and persist. Does nothing if it is absent.
+    pub fn update(
+        &mut self,
+        owner_pubkey: &str,
+        slug: &str,
+        f: impl FnOnce(&mut AgentRecord),
+    ) -> Result<()> {
+        if let Some(record) = self.state.agents.get_mut(&agent_key(owner_pubkey, slug)) {
+            f(record);
+            self.flush()?;
+        }
+        Ok(())
+    }
+
+    /// Remove an agent and persist, returning the removed record.
+    pub fn remove(&mut self, owner_pubkey: &str, slug: &str) -> Result<Option<AgentRecord>> {
+        let removed = self.state.agents.remove(&agent_key(owner_pubkey, slug));
+        if removed.is_some() {
+            self.flush()?;
+        }
+        Ok(removed)
+    }
+
+    /// Write the state file atomically with owner-only permissions.
+    fn flush(&self) -> Result<()> {
+        let json = serde_json::to_string_pretty(&self.state)
+            .context("failed to serialize spawner state")?;
+
+        // Write-then-rename: a crash leaves either the old complete file or the
+        // new complete file, never a truncated one holding half the agent keys.
+        let tmp = self.path.with_extension("json.tmp");
+        std::fs::write(&tmp, &json)
+            .with_context(|| format!("failed to write {}", tmp.display()))?;
+        restrict_permissions(&tmp)?;
+        std::fs::rename(&tmp, &self.path)
+            .with_context(|| format!("failed to rename into {}", self.path.display()))?;
+        Ok(())
+    }
+}
+
+#[cfg(unix)]
+fn restrict_permissions(path: &Path) -> Result<()> {
+    use std::os::unix::fs::PermissionsExt;
+    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
+        .with_context(|| format!("failed to chmod {}", path.display()))
+}
+
+#[cfg(not(unix))]
+fn restrict_permissions(_path: &Path) -> Result<()> {
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn record(owner: &str, slug: &str) -> AgentRecord {
+        AgentRecord {
+            slug: slug.into(),
+            owner_pubkey: owner.into(),
+            agent_pubkey: "a".repeat(64),
+            private_key_nsec: "nsec1test".into(),
+            auth_tag: None,
+            pending_nonce: None,
+            attestation_sent_at: None,
+            spec_hash: None,
+            prompt: None,
+            restart_count: 0,
+            last_failure_at: None,
+        }
+    }
+
+    #[test]
+    fn round_trips_through_the_file() {
+        let dir = std::env::temp_dir().join(format!("buzz-spawner-test-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+
+        let owner = "b".repeat(64);
+        {
+            let mut store = Store::open(&dir).unwrap();
+            store.put(record(&owner, "fizz")).unwrap();
+        }
+
+        let store = Store::open(&dir).unwrap();
+        assert_eq!(store.get(&owner, "fizz").unwrap().slug, "fizz");
+        assert!(store.get(&owner, "missing").is_none());
+
+        std::fs::remove_dir_all(&dir).unwrap();
+    }
+
+    #[test]
+    fn an_adopted_agent_is_not_startable_until_its_key_arrives() {
+        // A relocated agent is recorded from its spec before the owner delivers
+        // the key. Starting then would launch a container with an empty
+        // BUZZ_PRIVATE_KEY and fail deep inside the harness.
+        let mut rec = record(&"b".repeat(64), "fizz");
+        rec.private_key_nsec = String::new();
+        rec.auth_tag = Some(r#"["auth","o","","s"]"#.into());
+        assert!(!rec.is_attested());
+
+        rec.private_key_nsec = "nsec1real".into();
+        assert!(rec.is_attested());
+    }
+
+    #[test]
+    fn container_names_are_scoped_per_owner() {
+        // Slugs are client-chosen and only unique per owner. Two owners each
+        // naming an agent "fizz" must not fight over one container.
+        let a = record(&"a".repeat(64), "fizz");
+        let b = record(&"c".repeat(64), "fizz");
+        assert_ne!(a.container_name(), b.container_name());
+    }
+
+    #[test]
+    fn state_file_is_owner_only() {
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::PermissionsExt;
+            let dir =
+                std::env::temp_dir().join(format!("buzz-spawner-perm-{}", std::process::id()));
+            let _ = std::fs::remove_dir_all(&dir);
+
+            let mut store = Store::open(&dir).unwrap();
+            store.put(record(&"d".repeat(64), "fizz")).unwrap();
+
+            let mode = std::fs::metadata(dir.join("agents.json"))
+                .unwrap()
+                .permissions()
+                .mode();
+            assert_eq!(mode & 0o777, 0o600, "state file holds agent secret keys");
+
+            std::fs::remove_dir_all(&dir).unwrap();
+        }
+    }
+}
diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example
index f6ab4fcab9..14f88ddec1 100644
--- a/deploy/compose/.env.example
+++ b/deploy/compose/.env.example
@@ -36,6 +36,23 @@ BUZZ_S3_BUCKET=buzz-media
 # Bundled MinIO uses path-style URLs; deploy/compose/compose.yml pins this.
 BUZZ_S3_ADDRESSING_STYLE=path
 
+# Server-hosted agents (buzz-spawner). Only used with compose.spawner.yml,
+# enabled by BUZZ_COMPOSE_SPAWNER=true. Mounts the host Docker socket — see
+# README.md § Server-hosted agents before enabling.
+#
+# The spawner's own Nostr identity. Generate once and keep it stable: clients
+# address agent specs to this pubkey, and it is the pubkey owners approve when
+# signing an attestation.
+BUZZ_SPAWNER_NSEC=CHANGE_ME_SPAWNER_NSEC
+BUZZ_SPAWNER_IMAGE=ghcr.io/block/buzz-spawner:main
+BUZZ_SPAWNER_AGENT_IMAGE=ghcr.io/block/buzz-acp:main
+BUZZ_SPAWNER_MAX_AGENTS=16
+BUZZ_SPAWNER_MAX_CPU_MILLIS=4000
+BUZZ_SPAWNER_MAX_MEMORY_MIB=8192
+# Names of variables to forward into agent containers, not KEY=VALUE pairs.
+BUZZ_SPAWNER_AGENT_ENV=ANTHROPIC_API_KEY
+ANTHROPIC_API_KEY=CHANGE_ME_ANTHROPIC_API_KEY
+
 # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT.
 BUZZ_HTTP_PORT=3000
 
diff --git a/deploy/compose/README.md b/deploy/compose/README.md
index bb0e63fe15..3ffa610441 100644
--- a/deploy/compose/README.md
+++ b/deploy/compose/README.md
@@ -46,6 +46,68 @@ keypair.
 
 Run `./run.sh backup-hint` for the backup checklist.
 
+## Server-hosted agents (buzz-spawner)
+
+By default every Buzz agent is spawned by the desktop app, so agents stop when
+the laptop sleeps and cannot be created from mobile or web at all. The optional
+`spawner` service fixes that: it watches the relay for agent specs you publish
+and reconciles them into one isolated container per agent.
+
+```bash
+cd deploy/compose
+$EDITOR .env                              # set BUZZ_SPAWNER_NSEC + ANTHROPIC_API_KEY
+BUZZ_COMPOSE_SPAWNER=true ./run.sh start
+```
+
+### ⚠️ Trust boundary: the Docker socket
+
+**`compose.spawner.yml` mounts `/var/run/docker.sock` into the spawner
+container. That is root-equivalent access to the host** — anything that can talk
+to that socket can start a privileged container and take over the machine. This
+is why the service is opt-in rather than on by default.
+
+The spawner needs it because creating containers is its entire job. Agents run
+arbitrary shell and file-edit tools through `buzz-dev-mcp`, so running several as
+bare subprocesses of one daemon would let any agent read every other agent's
+workspace and secret key. Per-agent containers are the isolation.
+
+If you want to reduce the blast radius, point the spawner at a **rootless
+Docker or Podman socket** instead of the system one:
+
+```bash
+BUZZ_SPAWNER_DOCKER_SOCKET=/run/user/1000/docker.sock \
+  BUZZ_COMPOSE_SPAWNER=true ./run.sh start
+```
+
+### Notes
+
+- `BUZZ_ALLOW_NIP_OA_AUTH=true` is required. Spawned agents authenticate by
+  owner attestation; without it none of them can connect.
+- Agent containers are deliberately **not** attached to `buzz-net`. They reach
+  the relay at `BUZZ_SPAWNER_AGENT_RELAY_URL` (defaulting to `RELAY_URL`) the
+  same way any external client does, so an agent's shell cannot reach Postgres,
+  Redis, or MinIO directly.
+- **Both spawner URLs must use the relay's public host.** The relay derives the
+  community from the HTTP `Host` header, so `ws://relay:3000` is a *different
+  tenant* from `wss://your.domain`. Pointing the spawner at the internal compose
+  name puts it in a community your owner account is not in — status events never
+  reach you — or fails the connection outright with a 404, because internal
+  names have no community row.
+- `BUZZ_SPAWNER_NSEC` must stay stable. Clients address specs to this pubkey,
+  and it is the pubkey an owner approves when signing an attestation — rotating
+  it strands every existing agent.
+- `BUZZ_SPAWNER_AGENT_ENV` takes variable **names**, not `KEY=VALUE` pairs. The
+  values are read from the spawner's own environment, so a rotated credential is
+  picked up by restarting the spawner rather than by editing agent config.
+- Back up the `buzz-spawner-data` volume. It holds each agent's secret key,
+  minted on this host and never transmitted anywhere. Losing it orphans every
+  running agent: their pubkeys remain in your channels and relay membership, but
+  nothing can sign as them again.
+- Creating an agent needs a one-time approval from the owner's client. The
+  spawner mints the key, then asks the owner to sign a NIP-OA attestation for it
+  — it cannot self-attest. Until that is approved the agent's status event
+  reports `pending_attestation`.
+
 ## Validation
 
 Before sharing an install link publicly, verify a fresh install with:
diff --git a/deploy/compose/compose.spawner.yml b/deploy/compose/compose.spawner.yml
new file mode 100644
index 0000000000..751737d1b3
--- /dev/null
+++ b/deploy/compose/compose.spawner.yml
@@ -0,0 +1,72 @@
+# Server-hosted agents. Opt in with BUZZ_COMPOSE_SPAWNER=true ./run.sh start
+#
+# ⚠️  This override mounts the host's Docker socket into the spawner container.
+# That is root-equivalent access to the host: anything able to talk to that
+# socket can start a privileged container and take over the machine. Enable it
+# only on a host where you are comfortable with that, and read the trust-boundary
+# section in README.md first.
+#
+# The spawner needs the socket because its entire job is creating one isolated
+# container per agent — agents execute arbitrary shell and file-edit tools via
+# buzz-dev-mcp, so running them as bare subprocesses of one daemon would let any
+# agent read every other agent's workspace and secret key.
+#
+# To reduce the blast radius, point DOCKER_HOST at a rootless Docker or Podman
+# socket instead of the system one.
+
+services:
+  spawner:
+    image: ${BUZZ_SPAWNER_IMAGE:-ghcr.io/block/buzz-spawner:main}
+    # No `env_file: .env` here, unlike the relay. Everything the spawner needs is
+    # listed explicitly below, so it never holds the database, Redis, or S3
+    # credentials it has no use for.
+    environment:
+      BUZZ_SPAWNER_NSEC: ${BUZZ_SPAWNER_NSEC:?set BUZZ_SPAWNER_NSEC}
+      # Both URLs must use the relay's PUBLIC host, not the internal compose
+      # name. The relay derives which community a connection belongs to from the
+      # HTTP Host header, so `ws://relay:3000` is a different tenant from
+      # `wss://your.domain` — a spawner on the internal name would publish status
+      # into a community the owner is not in, and the owner would never see it
+      # (or, more likely, get a 404: internal names have no community row).
+      BUZZ_SPAWNER_RELAY_URL: ${BUZZ_SPAWNER_RELAY_URL:-${RELAY_URL:?set RELAY_URL}}
+      # Agent containers are deliberately NOT on buzz-net — an agent runs
+      # arbitrary shell tools, and joining that network would put Postgres,
+      # Redis, and MinIO one `nc` away. They dial the same public address, which
+      # also keeps them in the same community as their owner.
+      BUZZ_SPAWNER_AGENT_RELAY_URL: ${BUZZ_SPAWNER_AGENT_RELAY_URL:-${RELAY_URL:?set RELAY_URL}}
+      BUZZ_SPAWNER_STATE_DIR: /var/lib/buzz-spawner
+      BUZZ_SPAWNER_AGENT_IMAGE: ${BUZZ_SPAWNER_AGENT_IMAGE:-ghcr.io/block/buzz-acp:main}
+      # Which ACP agent runs inside each container. Unset keeps the image
+      # default (`buzz-agent`, metered API key). Set `claude-agent-acp` to run
+      # on a Claude subscription — then add CLAUDE_CODE_OAUTH_TOKEN to
+      # BUZZ_SPAWNER_AGENT_ENV below. Operator-only: it selects what executes,
+      # so no agent spec can influence it.
+      BUZZ_SPAWNER_AGENT_COMMAND: ${BUZZ_SPAWNER_AGENT_COMMAND:-}
+      BUZZ_SPAWNER_AGENT_ARGS: ${BUZZ_SPAWNER_AGENT_ARGS:-}
+      BUZZ_SPAWNER_MAX_AGENTS: ${BUZZ_SPAWNER_MAX_AGENTS:-16}
+      BUZZ_SPAWNER_MAX_CPU_MILLIS: ${BUZZ_SPAWNER_MAX_CPU_MILLIS:-4000}
+      BUZZ_SPAWNER_MAX_MEMORY_MIB: ${BUZZ_SPAWNER_MAX_MEMORY_MIB:-8192}
+      # Names of variables to forward into agent containers — not KEY=VALUE
+      # pairs. The value is read from this service's own environment, so a
+      # rotated credential is picked up by restarting the spawner.
+      BUZZ_SPAWNER_AGENT_ENV: ${BUZZ_SPAWNER_AGENT_ENV:-ANTHROPIC_API_KEY}
+      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
+      # Long-lived Claude subscription token from `claude setup-token`, run once
+      # on a machine with a browser. Only used when BUZZ_SPAWNER_AGENT_COMMAND
+      # is claude-agent-acp.
+      CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-}
+      RUST_LOG: ${BUZZ_SPAWNER_LOG:-buzz_spawner=info}
+    volumes:
+      - buzz-spawner-data:/var/lib/buzz-spawner
+      - ${BUZZ_SPAWNER_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
+    depends_on:
+      relay:
+        condition: service_healthy
+    restart: unless-stopped
+    networks:
+      - buzz-net
+
+volumes:
+  buzz-spawner-data:
+    labels:
+      com.buzz.volume: spawner
diff --git a/deploy/compose/run.sh b/deploy/compose/run.sh
index d5465ea1f5..ee2d6b06a8 100755
--- a/deploy/compose/run.sh
+++ b/deploy/compose/run.sh
@@ -11,6 +11,10 @@ fi
 if [[ "${BUZZ_COMPOSE_DEV:-false}" == "true" ]]; then
   COMPOSE_FILES+=(-f compose.dev.yml)
 fi
+# Opt-in: mounts the host Docker socket. See README.md § Server-hosted agents.
+if [[ "${BUZZ_COMPOSE_SPAWNER:-false}" == "true" ]]; then
+  COMPOSE_FILES+=(-f compose.spawner.yml)
+fi
 
 compose() {
   docker compose --env-file .env "${COMPOSE_FILES[@]}" "$@"
@@ -123,6 +127,9 @@ Commands:
 Environment switches:
   BUZZ_COMPOSE_TLS=true   Include compose.caddy.yml for automatic HTTPS
   BUZZ_COMPOSE_DEV=true   Include compose.dev.yml for local admin ports/tools
+  BUZZ_COMPOSE_SPAWNER=true
+                          Include compose.spawner.yml for server-hosted agents.
+                          Mounts the host Docker socket — read README.md first.
 MSG
     ;;
   *)
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 459fa75743..cd6c39a78c 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -20,6 +20,7 @@ export default defineConfig({
       name: "smoke",
       testMatch: [
         "**/smoke.spec.ts",
+        "**/server-agents-screenshots.spec.ts",
         "**/onboarding-docked-cta-screenshots.spec.ts",
         "**/identity-key-help.spec.ts",
         "**/key-import-reveal.spec.ts",
diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs
index 5a26f0f645..d6ef77bd0e 100644
--- a/desktop/src-tauri/src/commands/agent_config.rs
+++ b/desktop/src-tauri/src/commands/agent_config.rs
@@ -662,6 +662,7 @@ mod tests {
             env_vars: Default::default(),
             start_on_app_launch: false,
             auto_restart_on_config_change: true,
+            relocated_to_spawner: None,
             runtime_pid: None,
             backend: BackendKind::Local,
             backend_agent_id: None,
diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs
index 2317930c1e..9ef4408147 100644
--- a/desktop/src-tauri/src/commands/agent_settings.rs
+++ b/desktop/src-tauri/src/commands/agent_settings.rs
@@ -69,6 +69,58 @@ pub async fn set_managed_agent_start_on_app_launch(
     .map_err(|e| format!("spawn_blocking failed: {e}"))?
 }
 
+/// Record that this agent's identity was handed to a spawner (NIP-AS
+/// relocation). Pass `Some(spawner_pubkey)` right after a successful
+/// attestation hand-off, `None` to move the agent back to this device.
+///
+/// This is state, not an action: the flag is what every local start path —
+/// manual start, app-launch restore, runtime reconcile, auto-restart — checks
+/// before spawning, so a relocated identity can never run in two places.
+#[tauri::command]
+pub async fn set_managed_agent_relocated(
+    pubkey: String,
+    relocated_to_spawner: Option<String>,
+    app: AppHandle,
+) -> Result<ManagedAgentSummary, String> {
+    tokio::task::spawn_blocking(move || {
+        let state = app.state::<AppState>();
+        let _store_guard = state
+            .managed_agents_store_lock
+            .lock()
+            .map_err(|error| error.to_string())?;
+        let mut records = load_managed_agents(&app)?;
+        let mut runtimes = state
+            .managed_agent_processes
+            .lock()
+            .map_err(|error| error.to_string())?;
+
+        let (sync_changed, exited_pubkeys) =
+            sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
+        if sync_changed {
+            save_managed_agents(&app, &records)?;
+        }
+        for pubkey in &exited_pubkeys {
+            state.clear_agent_session_caches(pubkey);
+        }
+
+        {
+            let record = find_managed_agent_mut(&mut records, &pubkey)?;
+            record.relocated_to_spawner = relocated_to_spawner;
+            record.updated_at = now_iso();
+        }
+
+        save_managed_agents(&app, &records)?;
+        let record = records
+            .iter()
+            .find(|record| record.pubkey == pubkey)
+            .ok_or_else(|| format!("agent {pubkey} not found"))?;
+        let personas = load_personas(&app).unwrap_or_default();
+        build_managed_agent_summary(&app, record, &runtimes, &personas)
+    })
+    .await
+    .map_err(|e| format!("spawn_blocking failed: {e}"))?
+}
+
 #[tauri::command]
 pub async fn set_managed_agent_auto_restart(
     pubkey: String,
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 0758fc3aac..fedbf57809 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -875,6 +875,7 @@ pub async fn create_managed_agent(
                 input.start_on_app_launch
             },
             auto_restart_on_config_change: true,
+            relocated_to_spawner: None,
             runtime_pid: None,
             backend: input.backend.clone(),
             backend_agent_id: None,
diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs
index 03389d1d18..51ca71f3c5 100644
--- a/desktop/src-tauri/src/commands/agents_tests.rs
+++ b/desktop/src-tauri/src/commands/agents_tests.rs
@@ -31,6 +31,7 @@ fn bare_agent_record(
         persona_source_version: None,
         env_vars: BTreeMap::new(),
         start_on_app_launch: false,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: BackendKind::Local,
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 66ef7ef17b..69ad5ceb62 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -53,6 +53,7 @@ mod qr_download;
 mod relay_members;
 mod relay_reconnect;
 mod social;
+mod spawner;
 mod team_snapshot;
 mod teams;
 mod updater;
@@ -104,6 +105,7 @@ pub use qr_download::*;
 pub use relay_members::*;
 pub use relay_reconnect::*;
 pub use social::*;
+pub use spawner::*;
 pub use team_snapshot::*;
 pub use teams::*;
 pub use updater::*;
diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs
index 8ff7cfbd9b..7a0cc528a8 100644
--- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs
@@ -39,6 +39,7 @@ fn make_agent(
         persona_source_version: None,
         env_vars: BTreeMap::new(),
         start_on_app_launch: false,
+        relocated_to_spawner: None,
         runtime_pid,
         backend: BackendKind::Local,
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
index 1005a83432..0cce0200e5 100644
--- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
@@ -182,6 +182,7 @@ fn local_agent() -> ManagedAgentRecord {
         env_vars: BTreeMap::from([("API_KEY".to_string(), "localsecret".to_string())]),
         start_on_app_launch: true,
         auto_restart_on_config_change: true,
+        relocated_to_spawner: None,
         runtime_pid: Some(1234),
         backend: crate::managed_agents::BackendKind::Provider {
             id: "buzz-backend".to_string(),
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs
index d23efe7730..3835477aaf 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs
@@ -508,6 +508,7 @@ pub async fn confirm_agent_snapshot_import(
             env_vars: std::collections::BTreeMap::new(),
             start_on_app_launch: false,
             auto_restart_on_config_change: true,
+            relocated_to_spawner: None,
             runtime_pid: None,
             backend: crate::managed_agents::BackendKind::Local,
             backend_agent_id: None,
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs
index 4289310280..61279a8252 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs
@@ -45,6 +45,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
         env_vars: BTreeMap::new(),
         start_on_app_launch: false,
         auto_restart_on_config_change: false,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: BackendKind::Local,
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs
index c60215ae4d..13db25bda2 100644
--- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs
@@ -28,6 +28,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge
         env_vars: std::collections::BTreeMap::new(),
         start_on_app_launch: false,
         auto_restart_on_config_change: true,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: Default::default(),
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/commands/spawner.rs b/desktop/src-tauri/src/commands/spawner.rs
new file mode 100644
index 0000000000..f44cd791bd
--- /dev/null
+++ b/desktop/src-tauri/src/commands/spawner.rs
@@ -0,0 +1,324 @@
+//! Owner side of the NIP-AS spawner attestation handshake.
+//!
+//! A `buzz-spawner` daemon mints an agent's secret key on the server and never
+//! transmits it. But a NIP-OA auth tag binds one specific agent pubkey and is
+//! signed with the *owner's* secret key, so the spawner cannot self-attest — it
+//! has to ask. This module is the answer to that question.
+//!
+//! # Security: the auth tag is a delegation, not a formality
+//!
+//! Signing an attestation admits that agent pubkey to the relay under **this
+//! user's** membership. A malicious spawner that gets a signature can run an
+//! agent that reads the user's channels. So:
+//!
+//! - Signing is never automatic for an unrecognized spawner. The frontend must
+//!   have recorded an explicit trust decision first; [`respond_to_spawner_attestation`]
+//!   fails closed when it has not.
+//! - The tag is computed only for the agent pubkey named in the *decrypted*
+//!   frame, so a frame body cannot ask for a signature over some other key
+//!   while displaying a benign one.
+//! - The response is NIP-44 encrypted back to the same spawner that asked, so
+//!   an eavesdropper on the ephemeral kind:24201 stream gets ciphertext.
+
+use nostr::JsonUtil;
+use serde::{Deserialize, Serialize};
+
+use buzz_sdk_pkg::spawner::{build_spawner_attestation, AttestationFrame, PromptMaterial};
+
+use crate::app_state::AppState;
+use crate::managed_agents::load_managed_agents;
+
+/// The trusted-spawner decision the frontend has already made.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum SpawnerTrust {
+    /// The user has approved this spawner pubkey.
+    Trusted,
+    /// The user has not approved it, or explicitly declined.
+    Untrusted,
+}
+
+/// A decrypted attestation request, for display before the user decides.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct SpawnerAttestationRequest {
+    /// Spawner pubkey that sent the request, hex.
+    pub spawner_pubkey: String,
+    /// Spec slug the agent belongs to.
+    pub spec_slug: String,
+    /// The freshly minted agent pubkey the spawner wants attested.
+    pub agent_pubkey: String,
+    /// NIP-OA conditions the spawner is asking to be signed over.
+    pub conditions: String,
+    /// Handshake nonce, echoed back in the response.
+    pub nonce: String,
+}
+
+/// Decrypt an inbound kind:24201 frame so the UI can show the user what is
+/// being asked before anything is signed.
+///
+/// Returns `Ok(None)` for a frame that is not a request — responses and
+/// rejections are the owner's own outbound traffic echoed back, and are not
+/// something to prompt about.
+#[tauri::command]
+pub async fn decode_spawner_attestation(
+    state: tauri::State<'_, AppState>,
+    spawner_pubkey: String,
+    encrypted_content: String,
+) -> Result<Option<SpawnerAttestationRequest>, String> {
+    let keys = state.signing_keys()?;
+    let spawner = nostr::PublicKey::from_hex(&spawner_pubkey)
+        .map_err(|e| format!("invalid spawner pubkey: {e}"))?;
+
+    let frame = decrypt_frame(&keys, &spawner, &encrypted_content)?;
+
+    match frame {
+        AttestationFrame::Request {
+            spec_slug,
+            agent_pubkey,
+            conditions,
+            nonce,
+        } => Ok(Some(SpawnerAttestationRequest {
+            spawner_pubkey: spawner.to_hex(),
+            spec_slug,
+            agent_pubkey,
+            conditions,
+            nonce,
+        })),
+        _ => Ok(None),
+    }
+}
+
+/// Build the signed answer to an attestation request: sign the NIP-OA tag when
+/// `trust` is [`SpawnerTrust::Trusted`], and an explicit rejection otherwise.
+///
+/// Rejecting explicitly rather than staying silent matters — it lets the
+/// spawner report a clear `failed` status immediately instead of leaving the
+/// agent stuck in `pending_attestation` until its timeout expires.
+///
+/// # Why this returns the event instead of publishing it
+///
+/// Kind 24201 is ephemeral. The relay routes ephemeral events through its
+/// WebSocket handler; `POST /events` runs the ingest path, whose per-kind scope
+/// allowlist has no arm for ephemeral kinds and rejects them with
+/// `restricted: unknown event kind`. Kind 24200 observer control frames solve
+/// this the same way — `build_observer_control_event` signs here and the
+/// renderer publishes over the live socket.
+/// Outcome of answering an attestation, for the caller to act on.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SpawnerAttestationResponse {
+    /// The signed kind:24201 event to publish over the WebSocket.
+    pub event_json: String,
+    /// Set when this handover relocated a locally-managed agent.
+    ///
+    /// The caller MUST stop the local process for this pubkey: two runners
+    /// holding one key both see every mention and both reply, so the agent
+    /// answers twice and burns two turns per message.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub relocated_agent_pubkey: Option<String>,
+}
+
+#[tauri::command]
+pub async fn respond_to_spawner_attestation(
+    app: tauri::AppHandle,
+    state: tauri::State<'_, AppState>,
+    spawner_pubkey: String,
+    encrypted_content: String,
+    trust: SpawnerTrust,
+    reject_reason: Option<String>,
+    prompt: Option<PromptMaterial>,
+) -> Result<SpawnerAttestationResponse, String> {
+    let keys = state.signing_keys()?;
+    let spawner = nostr::PublicKey::from_hex(&spawner_pubkey)
+        .map_err(|e| format!("invalid spawner pubkey: {e}"))?;
+
+    // Re-decrypt rather than trusting fields passed back from the frontend. The
+    // signature must cover the pubkey the spawner actually asked about, not one
+    // the renderer could have substituted after the user saw the prompt.
+    let frame = decrypt_frame(&keys, &spawner, &encrypted_content)?;
+    let AttestationFrame::Request {
+        spec_slug,
+        agent_pubkey,
+        conditions,
+        nonce,
+    } = frame
+    else {
+        return Err("attestation frame is not a request".into());
+    };
+
+    // Relocation: when the spawner is asking about an agent this device already
+    // manages, the answer carries that agent's existing key so the SAME identity
+    // moves — its channels, profile, and NIP-AE memory all hang off that pubkey,
+    // and a fresh key would strand every one of them. Looked up from the local
+    // store rather than taken from the frame, so a spawner cannot name an
+    // arbitrary pubkey and be handed a key for it.
+    let mut relocated_agent_pubkey = None;
+    let mut relocation_nsec = None;
+    if matches!(trust, SpawnerTrust::Trusted) {
+        let managed = load_managed_agents(&app)?;
+        if let Some(record) = managed
+            .iter()
+            .find(|record| record.pubkey.eq_ignore_ascii_case(&agent_pubkey))
+        {
+            if record.private_key_nsec.trim().is_empty() {
+                return Err(format!(
+                    "cannot relocate {}: its key is unavailable on this device",
+                    record.name
+                ));
+            }
+            relocated_agent_pubkey = Some(record.pubkey.clone());
+            relocation_nsec = Some(record.private_key_nsec.clone());
+        }
+    }
+
+    let response = match trust {
+        SpawnerTrust::Untrusted => AttestationFrame::Reject {
+            spec_slug,
+            agent_pubkey,
+            nonce,
+            reason: Some(
+                reject_reason.unwrap_or_else(|| "owner has not approved this spawner".into()),
+            ),
+        },
+        SpawnerTrust::Trusted => {
+            let agent = nostr::PublicKey::from_hex(&agent_pubkey)
+                .map_err(|e| format!("invalid agent pubkey in attestation request: {e}"))?;
+            let auth_tag = buzz_sdk_pkg::nip_oa::compute_auth_tag(&keys, &agent, &conditions)
+                .map_err(|e| format!("failed to compute owner auth tag: {e}"))?;
+            AttestationFrame::Response {
+                spec_slug,
+                agent_pubkey,
+                nonce,
+                auth_tag,
+                private_key_nsec: relocation_nsec,
+                // Prompt material rides the encrypted channel rather than the
+                // world-readable spec, so an agent's instructions never become
+                // public. Absent for a shared persona the spawner can read.
+                prompt: prompt.filter(|p| !p.is_empty()),
+            }
+        }
+    };
+
+    let plaintext = serde_json::to_string(&response)
+        .map_err(|e| format!("failed to serialize attestation response: {e}"))?;
+    let ciphertext = nostr::nips::nip44::encrypt(
+        keys.secret_key(),
+        &spawner,
+        plaintext,
+        nostr::nips::nip44::Version::V2,
+    )
+    .map_err(|e| format!("failed to encrypt attestation response: {e}"))?;
+
+    let event = build_spawner_attestation(&spawner.to_hex(), &ciphertext)
+        .map_err(|e| format!("failed to build attestation event: {e}"))?
+        .sign_with_keys(&keys)
+        .map_err(|e| format!("failed to sign attestation event: {e}"))?;
+    Ok(SpawnerAttestationResponse {
+        event_json: event.as_json(),
+        relocated_agent_pubkey,
+    })
+}
+
+fn decrypt_frame(
+    keys: &nostr::Keys,
+    spawner: &nostr::PublicKey,
+    encrypted_content: &str,
+) -> Result<AttestationFrame, String> {
+    let plaintext = nostr::nips::nip44::decrypt(keys.secret_key(), spawner, encrypted_content)
+        .map_err(|e| format!("failed to decrypt attestation frame: {e}"))?;
+    let frame: AttestationFrame = serde_json::from_str(&plaintext)
+        .map_err(|e| format!("failed to parse attestation frame: {e}"))?;
+    frame
+        .validate()
+        .map_err(|e| format!("malformed attestation frame: {e}"))?;
+    Ok(frame)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use buzz_sdk_pkg::spawner::ATTESTATION_NONCE_BYTES;
+    use nostr::Keys;
+
+    fn encrypted_request(spawner: &Keys, owner: &Keys, agent_pubkey: &str) -> String {
+        let frame = AttestationFrame::Request {
+            spec_slug: "fizz-prod".into(),
+            agent_pubkey: agent_pubkey.into(),
+            conditions: String::new(),
+            nonce: "ab".repeat(ATTESTATION_NONCE_BYTES),
+        };
+        nostr::nips::nip44::encrypt(
+            spawner.secret_key(),
+            &owner.public_key(),
+            serde_json::to_string(&frame).unwrap(),
+            nostr::nips::nip44::Version::V2,
+        )
+        .unwrap()
+    }
+
+    #[test]
+    fn decodes_a_request_addressed_to_this_owner() {
+        let owner = Keys::generate();
+        let spawner = Keys::generate();
+        let agent = Keys::generate();
+        let ciphertext = encrypted_request(&spawner, &owner, &agent.public_key().to_hex());
+
+        let frame = decrypt_frame(&owner, &spawner.public_key(), &ciphertext).unwrap();
+        assert_eq!(frame.agent_pubkey(), agent.public_key().to_hex());
+        assert_eq!(frame.spec_slug(), "fizz-prod");
+    }
+
+    #[test]
+    fn cannot_decrypt_a_frame_meant_for_someone_else() {
+        let owner = Keys::generate();
+        let stranger = Keys::generate();
+        let spawner = Keys::generate();
+        let agent = Keys::generate();
+        let ciphertext = encrypted_request(&spawner, &stranger, &agent.public_key().to_hex());
+
+        assert!(decrypt_frame(&owner, &spawner.public_key(), &ciphertext).is_err());
+    }
+
+    #[test]
+    fn the_signed_tag_verifies_for_the_requested_agent_only() {
+        // The tag must cover exactly the pubkey in the decrypted frame. A tag
+        // that verified for any other key would let a spawner get a signature
+        // for an agent the user never saw.
+        let owner = Keys::generate();
+        let agent = Keys::generate();
+        let other = Keys::generate();
+
+        let auth_tag =
+            buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap();
+
+        assert_eq!(
+            buzz_sdk_pkg::nip_oa::verify_auth_tag(&auth_tag, &agent.public_key()).unwrap(),
+            owner.public_key()
+        );
+        assert!(buzz_sdk_pkg::nip_oa::verify_auth_tag(&auth_tag, &other.public_key()).is_err());
+    }
+
+    #[test]
+    fn a_malformed_frame_is_refused_before_anything_is_signed() {
+        let owner = Keys::generate();
+        let spawner = Keys::generate();
+        // Valid JSON, valid encryption, but a nonce of the wrong width.
+        let frame = serde_json::json!({
+            "type": "request",
+            "spec_slug": "fizz-prod",
+            "agent_pubkey": Keys::generate().public_key().to_hex(),
+            "conditions": "",
+            "nonce": "short",
+        });
+        let ciphertext = nostr::nips::nip44::encrypt(
+            spawner.secret_key(),
+            &owner.public_key(),
+            frame.to_string(),
+            nostr::nips::nip44::Version::V2,
+        )
+        .unwrap();
+
+        assert!(decrypt_frame(&owner, &spawner.public_key(), &ciphertext).is_err());
+    }
+}
diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs
index 91a0126f58..0fda3fe2ea 100644
--- a/desktop/src-tauri/src/commands/team_snapshot.rs
+++ b/desktop/src-tauri/src/commands/team_snapshot.rs
@@ -576,6 +576,7 @@ pub async fn confirm_team_snapshot_import(
             env_vars: std::collections::BTreeMap::new(),
             start_on_app_launch: false,
             auto_restart_on_config_change: true,
+            relocated_to_spawner: None,
             runtime_pid: None,
             backend: crate::managed_agents::BackendKind::Local,
             backend_agent_id: None,
diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs
index 0616411307..5555dc7a46 100644
--- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs
+++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs
@@ -203,6 +203,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
         env_vars: Default::default(),
         start_on_app_launch: false,
         auto_restart_on_config_change: true,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: crate::managed_agents::BackendKind::Local,
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 35f4eae866..f6842528ac 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -695,6 +695,8 @@ pub fn run() {
             get_media_proxy_port,
             fetch_link_preview_title,
             discover_acp_auth_methods,
+            decode_spawner_attestation,
+            respond_to_spawner_attestation,
             discover_acp_providers,
             discover_git_bash_prerequisite,
             install_acp_runtime,
@@ -782,6 +784,7 @@ pub fn run() {
             set_agent_managed_profiles,
             set_managed_agent_start_on_app_launch,
             set_managed_agent_auto_restart,
+            set_managed_agent_relocated,
             delete_managed_agent,
             get_managed_agent_log,
             get_agent_models,
diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs
index 4a7b80079d..51176c3585 100644
--- a/desktop/src-tauri/src/managed_agents/agent_events.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_events.rs
@@ -181,6 +181,7 @@ mod tests {
             env_vars: BTreeMap::from([("OPENAI_API_KEY".to_string(), "sk-secret".to_string())]),
             start_on_app_launch: true,
             auto_restart_on_config_change: true,
+            relocated_to_spawner: None,
             runtime_pid: Some(4242),
             backend: super::super::BackendKind::Provider {
                 id: "buzz-backend-x".to_string(),
diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
index 16a0d35b23..a3581d259c 100644
--- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
@@ -511,6 +511,7 @@ mod tests {
             },
             start_on_app_launch: true,
             auto_restart_on_config_change: true,
+            relocated_to_spawner: None,
             runtime_pid: Some(12345), // MUST NOT appear
             backend: BackendKind::Provider {
                 // MUST NOT appear — carries a provider secret
diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs
index 4ee4ec79c3..9c0191457b 100644
--- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs
@@ -84,6 +84,7 @@ fn test_record() -> ManagedAgentRecord {
         env_vars: BTreeMap::new(),
         start_on_app_launch: false,
         auto_restart_on_config_change: true,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: crate::managed_agents::types::BackendKind::Local,
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
index 1b587dca0e..1ca8fb1275 100644
--- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
@@ -336,6 +336,7 @@ fn record_with(
         persona_source_version: None,
         start_on_app_launch: false,
         auto_restart_on_config_change: true,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: Default::default(),
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs
index 553596e226..3fc8be327e 100644
--- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs
@@ -321,6 +321,7 @@ fn bare_record() -> ManagedAgentRecord {
         persona_source_version: None,
         env_vars: BTreeMap::new(),
         start_on_app_launch: false,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: BackendKind::Local,
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs
index 031b049a49..eb9f3f138d 100644
--- a/desktop/src-tauri/src/managed_agents/nest/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs
@@ -471,6 +471,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
         persona_source_version: None,
         start_on_app_launch: false,
         auto_restart_on_config_change: true,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: BackendKind::default(),
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs
index fa8eb36fa1..e53921383a 100644
--- a/desktop/src-tauri/src/managed_agents/readiness.rs
+++ b/desktop/src-tauri/src/managed_agents/readiness.rs
@@ -1502,6 +1502,7 @@ mod tests {
             env_vars,
             start_on_app_launch: false,
             auto_restart_on_config_change: true,
+            relocated_to_spawner: None,
             runtime_pid: None,
             backend: Default::default(),
             backend_agent_id: None,
diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs
index 1910620159..361b13b5d8 100644
--- a/desktop/src-tauri/src/managed_agents/restore.rs
+++ b/desktop/src-tauri/src/managed_agents/restore.rs
@@ -165,7 +165,13 @@ pub async fn restore_managed_agents_on_launch(
 
         let candidates: Vec<String> = records
             .iter()
-            .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
+            .filter(|record| {
+                record.start_on_app_launch
+                    && record.backend == BackendKind::Local
+                    // A relocated identity runs on its spawner; resurrecting it
+                    // here would answer every mention twice.
+                    && record.relocated_to_spawner.is_none()
+            })
             .map(|record| record.pubkey.clone())
             .collect();
 
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index f3b4cb67fd..edcadb7cb7 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -335,6 +335,7 @@ pub fn build_managed_agent_summary(
         last_error_code: record.last_error_code,
         start_on_app_launch: record.start_on_app_launch,
         auto_restart_on_config_change: record.auto_restart_on_config_change,
+        relocated_to_spawner: record.relocated_to_spawner.clone(),
         log_path,
         respond_to: record.respond_to,
         respond_to_allowlist: record.respond_to_allowlist.clone(),
@@ -461,6 +462,12 @@ pub fn spawn_agent_child(
     if let Some(error) = spawn_key_refusal(record) {
         return Err(error);
     }
+    // Relocated identities live on their spawner now; every spawn path funnels
+    // through here, so this closes local resurrection (manual start, restore,
+    // reconcile, lazy @mention wake, auto-restart) in one place.
+    if let Some(error) = super::spawn_relocation_refusal(record) {
+        return Err(error);
+    }
     let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?;
     // Resolve the effective harness (agent command) from the linked persona, so
     // persona harness edits propagate on the next spawn; an explicit per-agent
diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
index 3f6ee996f6..75ae2bbca4 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
@@ -151,6 +151,7 @@ fn fixture(
         env_vars: std::collections::BTreeMap::new(),
         start_on_app_launch: false,
         auto_restart_on_config_change: true,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: Default::default(),
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs
index c0e55184b1..d3e730e07f 100644
--- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs
@@ -466,9 +466,13 @@ pub async fn reconcile_managed_agent_runtimes(
     let records = load_managed_agents(&app)?;
     let mut jobs = Vec::new();
     for community in communities {
-        for record in records
-            .iter()
-            .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
+        for record in records.iter().filter(|record| {
+            record.start_on_app_launch
+                    && record.backend == BackendKind::Local
+                    // Relocated identities run on their spawner — never fan
+                    // out a local pair for them.
+                    && record.relocated_to_spawner.is_none()
+        })
         // The legacy per-record relay pin is deliberately ignored here — see
         // `effective_agent_relay_url`. Every local auto-start agent fans out
         // to every configured community.
diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs
index f4ad404814..5f144f429b 100644
--- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs
@@ -27,6 +27,7 @@ fn record() -> ManagedAgentRecord {
         env_vars: BTreeMap::new(),
         start_on_app_launch: false,
         auto_restart_on_config_change: true,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: Default::default(),
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs
index f6f89ed898..5bcbdde881 100644
--- a/desktop/src-tauri/src/managed_agents/storage.rs
+++ b/desktop/src-tauri/src/managed_agents/storage.rs
@@ -206,6 +206,21 @@ pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option<String> {
     })
 }
 
+/// Refuse to spawn an agent whose identity has been relocated to a spawner.
+/// Once the attestation hand-off gives the spawner this agent's secret key,
+/// the server copy is the agent — starting a local process too would run one
+/// identity in two places (duplicate replies, two turns billed per message).
+/// Every local spawn path must check this and fail closed.
+pub(crate) fn spawn_relocation_refusal(record: &ManagedAgentRecord) -> Option<String> {
+    record.relocated_to_spawner.as_ref().map(|spawner| {
+        format!(
+            "agent {} now runs on a server (spawner {}). Refusing to start a local copy — \
+             it would answer every mention twice.",
+            record.name, spawner
+        )
+    })
+}
+
 /// Read the raw unified store — keyed instances AND key-less definitions —
 /// with fail-loud parse handling. Internal seam; public readers filter.
 fn load_agent_store(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, String> {
diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs
index 73567bb915..0be41b794f 100644
--- a/desktop/src-tauri/src/managed_agents/storage_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs
@@ -251,6 +251,41 @@ fn spawn_allowed_when_private_key_present() {
     assert!(super::spawn_key_refusal(&record).is_none());
 }
 
+#[test]
+fn spawn_refused_when_relocated_to_a_spawner() {
+    // After a relocation hand-off the spawner holds this identity's key.
+    // Starting it here too would run one identity in two places: duplicate
+    // replies, two turns billed per message. Every spawn path must refuse.
+    let mut record = record_with_key("nsec1realkey");
+    record.relocated_to_spawner = Some("a".repeat(64));
+    assert!(
+        super::spawn_relocation_refusal(&record).is_some(),
+        "a relocated agent must never be started locally"
+    );
+}
+
+#[test]
+fn spawn_allowed_when_not_relocated() {
+    let record = record_with_key("nsec1realkey");
+    assert!(super::spawn_relocation_refusal(&record).is_none());
+}
+
+#[test]
+fn relocation_field_defaults_to_none_and_stays_out_of_json_when_unset() {
+    // Pre-relocation stores must keep parsing (serde default), and an
+    // unset field must not appear in serialized JSON.
+    let record = record_with_key("nsec1realkey");
+    assert_eq!(record.relocated_to_spawner, None);
+    let json = serde_json::to_string(&record).expect("serialize");
+    assert!(!json.contains("relocated_to_spawner"));
+
+    let mut relocated = record_with_key("nsec1realkey");
+    relocated.relocated_to_spawner = Some("b".repeat(64));
+    let json = serde_json::to_string(&relocated).expect("serialize");
+    let back: ManagedAgentRecord = serde_json::from_str(&json).expect("round-trip");
+    assert_eq!(back.relocated_to_spawner, Some("b".repeat(64)));
+}
+
 #[test]
 fn persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared() {
     // This is the dominant prompt-storm scenario: after the first successful
diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs
index 96082acc76..9fa7ec9609 100644
--- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs
+++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs
@@ -280,6 +280,7 @@ mod tests {
             },
             start_on_app_launch: false,
             auto_restart_on_config_change: true,
+            relocated_to_spawner: None,
             runtime_pid: None,
             backend: BackendKind::Local,
             backend_agent_id: None,
diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs
index 1ffa60eda9..434e9a8c99 100644
--- a/desktop/src-tauri/src/managed_agents/teams_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs
@@ -187,6 +187,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord {
         env_vars: std::collections::BTreeMap::new(),
         start_on_app_launch: false,
         auto_restart_on_config_change: false,
+        relocated_to_spawner: None,
         runtime_pid: None,
         backend: crate::managed_agents::BackendKind::Local,
         backend_agent_id: None,
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index 3d8e0ed02b..027a29e55b 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -122,6 +122,7 @@ impl AgentDefinition {
             env_vars: self.env_vars,
             start_on_app_launch: false,
             auto_restart_on_config_change: true,
+            relocated_to_spawner: None,
             runtime_pid: None,
             backend: BackendKind::default(),
             backend_agent_id: None,
@@ -314,6 +315,13 @@ pub struct ManagedAgentRecord {
     /// frontend only fires when the agent is idle, connected, and local.
     #[serde(default = "default_auto_restart_on_config_change")]
     pub auto_restart_on_config_change: bool,
+    /// Set (to the spawner's pubkey) when this agent's secret key was handed
+    /// to a `buzz-spawner` over the attestation handshake — the identity now
+    /// runs on that server. A relocated record is retained for provenance and
+    /// a possible move-back, but every local spawn path must refuse it: two
+    /// runners holding one key both answer every mention.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub relocated_to_spawner: Option<String>,
     #[serde(default)]
     pub runtime_pid: Option<u32>,
     #[serde(default)]
@@ -563,6 +571,10 @@ pub struct ManagedAgentSummary {
     pub last_error_code: Option<i64>,
     pub start_on_app_launch: bool,
     pub auto_restart_on_config_change: bool,
+    /// Mirrors `ManagedAgentRecord.relocated_to_spawner`: `Some(spawner
+    /// pubkey)` when this identity was handed to a server. The UI shows
+    /// "runs on server" instead of Start, and the auto-restart policy holds.
+    pub relocated_to_spawner: Option<String>,
     pub log_path: String,
     pub respond_to: RespondTo,
     pub respond_to_allowlist: Vec<String>,
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index abbbf29610..cbddb4e5c6 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -41,6 +41,8 @@ import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManag
 import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy";
 import { usePersonaSync } from "@/features/agents/lib/usePersonaSync";
 import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion";
+import { useSpawnerIngestion } from "@/features/agents/useSpawnerIngestion";
+import { SpawnerAttestationDialog } from "@/features/agents/ui/SpawnerAttestationDialog";
 import { AgentManagementDialogs } from "@/features/agents/ui/AgentManagementDialogs";
 import { RequestedAgentCreateDialogs } from "@/features/agents/ui/RequestedAgentCreateDialogs";
 import {
@@ -182,6 +184,9 @@ export function AppShell() {
   // relay-owned agents join automatically once identity arrives. Adding a
   // guard here would drop managed-agent coverage during startup.
   useAgentObserverIngestion();
+  // Kind 24201 is ephemeral and relay-routed by `#p`: a request that arrives
+  // while the Agents screen is unmounted is gone, so this is app-wide.
+  useSpawnerIngestion();
   // Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not
   // deferred) and unconditionally repairs the DB subscription on internal
   // builds — otherwise frames emitted before the listener opens are lost.
@@ -941,6 +946,7 @@ export function AppShell() {
                     )}
                     <RequestedAgentCreateDialogs />
                     <AgentManagementDialogs />
+                    <SpawnerAttestationDialog />
                     <AppShellOverlays
                       activeChannel={managedChannel}
                       browseDialogType={browseDialogType}
diff --git a/desktop/src/features/agents/agentLocation.test.mjs b/desktop/src/features/agents/agentLocation.test.mjs
new file mode 100644
index 0000000000..19f9009365
--- /dev/null
+++ b/desktop/src/features/agents/agentLocation.test.mjs
@@ -0,0 +1,82 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  LOCAL,
+  resolveDefaultAgentLocation,
+  sameLocation,
+  setDefaultAgentLocation,
+} from "./agentLocation.ts";
+
+const SPAWNER_A = "aa".repeat(32);
+const SPAWNER_B = "bb".repeat(32);
+
+test("defaultsToLocal", () => {
+  assert.deepEqual(resolveDefaultAgentLocation([]), LOCAL);
+});
+
+test("storesASpawnerAsTheDefault", () => {
+  assert.equal(
+    setDefaultAgentLocation({ kind: "spawner", spawnerPubkey: SPAWNER_A }),
+    true,
+  );
+  assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_A]), {
+    kind: "spawner",
+    spawnerPubkey: SPAWNER_A,
+  });
+});
+
+test("fallsBackToLocalWhenTheDefaultSpawnerIsDisconnected", () => {
+  // Otherwise every new agent would target a spawner this device no longer
+  // manages and fail to deploy, with no obvious cause.
+  setDefaultAgentLocation({ kind: "spawner", spawnerPubkey: SPAWNER_A });
+  assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_B]), LOCAL);
+  // Reconnecting restores it: the preference is not destroyed by the fallback.
+  assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_A]), {
+    kind: "spawner",
+    spawnerPubkey: SPAWNER_A,
+  });
+});
+
+test("rejectsAMalformedSpawnerPubkey", () => {
+  assert.equal(
+    setDefaultAgentLocation({ kind: "spawner", spawnerPubkey: "nope" }),
+    false,
+  );
+});
+
+test("normalisesCaseSoLookupsMatchConnectedSpawners", () => {
+  setDefaultAgentLocation({
+    kind: "spawner",
+    spawnerPubkey: SPAWNER_A.toUpperCase(),
+  });
+  assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_A]), {
+    kind: "spawner",
+    spawnerPubkey: SPAWNER_A,
+  });
+});
+
+test("localCanBeSetBackExplicitly", () => {
+  setDefaultAgentLocation({ kind: "spawner", spawnerPubkey: SPAWNER_A });
+  setDefaultAgentLocation(LOCAL);
+  assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_A]), LOCAL);
+});
+
+test("sameLocationComparesKindAndSpawner", () => {
+  assert.ok(sameLocation(LOCAL, LOCAL));
+  assert.ok(
+    sameLocation(
+      { kind: "spawner", spawnerPubkey: SPAWNER_A },
+      { kind: "spawner", spawnerPubkey: SPAWNER_A },
+    ),
+  );
+  assert.ok(
+    !sameLocation(
+      { kind: "spawner", spawnerPubkey: SPAWNER_A },
+      { kind: "spawner", spawnerPubkey: SPAWNER_B },
+    ),
+  );
+  assert.ok(
+    !sameLocation(LOCAL, { kind: "spawner", spawnerPubkey: SPAWNER_A }),
+  );
+});
diff --git a/desktop/src/features/agents/agentLocation.ts b/desktop/src/features/agents/agentLocation.ts
new file mode 100644
index 0000000000..8f88c0d4c5
--- /dev/null
+++ b/desktop/src/features/agents/agentLocation.ts
@@ -0,0 +1,157 @@
+import React from "react";
+
+import { getSpawners, useSpawners } from "./spawnerPreference";
+
+/**
+ * Where an agent runs — deliberately separate from *which runtime* it runs.
+ *
+ * The two are orthogonal. A runtime (goose, Claude Code, codex, buzz-agent) says
+ * which binary drives the agent; a location says whose machine that binary runs
+ * on. Folding "server" into the runtime list would conflate them, and would make
+ * "Claude Code" silently mean "Claude Code, locally".
+ *
+ * `{ kind: "local" }` is the historical behaviour: the desktop spawns the
+ * harness itself, and the agent dies with the app. `{ kind: "spawner" }` means a
+ * `buzz-spawner` runs it, and it keeps working when the app is closed.
+ *
+ * The *runtime* of a server agent is not represented here on purpose: the host
+ * decides what executes there, and the spawner merely advertises it for display
+ * (see `SpawnerAnnouncement.runtime`).
+ */
+export type AgentLocation =
+  | { kind: "local" }
+  | { kind: "spawner"; spawnerPubkey: string };
+
+/** The location used for new agents when nothing more specific applies. */
+const STORAGE_KEY = "buzz:default-agent-location";
+
+export const LOCAL: AgentLocation = { kind: "local" };
+
+const listeners = new Set<() => void>();
+
+let defaultLocation: AgentLocation = readStored();
+
+function isPubkeyHex(value: string): boolean {
+  return value.length === 64 && /^[0-9a-f]+$/i.test(value);
+}
+
+function readStored(): AgentLocation {
+  try {
+    const raw = window.localStorage.getItem(STORAGE_KEY);
+    if (!raw) return LOCAL;
+    // Stored as the spawner pubkey, or "local". A bare pubkey keeps the format
+    // trivially inspectable in devtools.
+    if (raw === "local") return LOCAL;
+    if (isPubkeyHex(raw)) {
+      return { kind: "spawner", spawnerPubkey: raw.toLowerCase() };
+    }
+    return LOCAL;
+  } catch {
+    return LOCAL;
+  }
+}
+
+function notify(): void {
+  for (const listener of listeners) listener();
+}
+
+/**
+ * Set the default location for new agents.
+ *
+ * Returns false for a malformed spawner pubkey so the caller can surface a
+ * validation message instead of silently falling back to local.
+ */
+export function setDefaultAgentLocation(location: AgentLocation): boolean {
+  if (location.kind === "spawner" && !isPubkeyHex(location.spawnerPubkey)) {
+    return false;
+  }
+  defaultLocation =
+    location.kind === "local"
+      ? LOCAL
+      : {
+          kind: "spawner",
+          spawnerPubkey: location.spawnerPubkey.toLowerCase(),
+        };
+  try {
+    window.localStorage.setItem(
+      STORAGE_KEY,
+      defaultLocation.kind === "local"
+        ? "local"
+        : defaultLocation.spawnerPubkey,
+    );
+  } catch {
+    // Keep the in-memory value; the choice still applies this session.
+  }
+  notify();
+  return true;
+}
+
+/**
+ * The default location, falling back to local when its spawner is gone.
+ *
+ * A device can be disconnected from the spawner that was the default. Returning
+ * a location that points at a spawner this device no longer manages would make
+ * every new agent fail to deploy, so the fallback is explicit rather than
+ * stored — reconnecting restores the preference.
+ */
+export function resolveDefaultAgentLocation(
+  connectedSpawners: readonly string[] = getSpawners(),
+): AgentLocation {
+  if (
+    defaultLocation.kind === "spawner" &&
+    !connectedSpawners.includes(defaultLocation.spawnerPubkey)
+  ) {
+    return LOCAL;
+  }
+  return defaultLocation;
+}
+
+/** The raw stored default, ignoring whether its spawner is still connected. */
+export function getStoredDefaultAgentLocation(): AgentLocation {
+  return defaultLocation;
+}
+
+function subscribe(listener: () => void): () => void {
+  listeners.add(listener);
+  return () => {
+    listeners.delete(listener);
+  };
+}
+
+function getSnapshot(): AgentLocation {
+  return defaultLocation;
+}
+
+function getServerSnapshot(): AgentLocation {
+  return LOCAL;
+}
+
+/**
+ * Reactive default location, already resolved against connected spawners.
+ *
+ * This is what a create-agent flow should read: personas — including the
+ * built-in Fizz/Honey/Bumble — inherit it rather than each carrying their own
+ * copy, so changing the default moves every agent that has not been given an
+ * explicit location.
+ */
+export function useDefaultAgentLocation(): AgentLocation {
+  const stored = React.useSyncExternalStore(
+    subscribe,
+    getSnapshot,
+    getServerSnapshot,
+  );
+  const spawners = useSpawners();
+  if (stored.kind === "spawner" && !spawners.includes(stored.spawnerPubkey)) {
+    return LOCAL;
+  }
+  return stored;
+}
+
+/** Whether two locations refer to the same place. */
+export function sameLocation(a: AgentLocation, b: AgentLocation): boolean {
+  if (a.kind !== b.kind) return false;
+  if (a.kind === "spawner" && b.kind === "spawner") {
+    return a.spawnerPubkey === b.spawnerPubkey;
+  }
+  return true;
+}
diff --git a/desktop/src/features/agents/agentRelocation.test.mjs b/desktop/src/features/agents/agentRelocation.test.mjs
new file mode 100644
index 0000000000..4cbe924ef2
--- /dev/null
+++ b/desktop/src/features/agents/agentRelocation.test.mjs
@@ -0,0 +1,80 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  buildRelocationPlan,
+  isRelocationOfLocalAgent,
+} from "./agentRelocation.ts";
+
+const AGENT_PUBKEY =
+  "3333333333333333333333333333333333333333333333333333333333333333";
+
+test("carriesTheExistingPubkeySoTheSpawnerReusesTheIdentity", () => {
+  // Without `agentPubkey` the spawner mints a fresh key, which orphans the
+  // agent's NIP-AE memory forever. This field is the entire relocation.
+  const { slug, spec } = buildRelocationPlan({
+    pubkey: AGENT_PUBKEY,
+    name: "Fizz (prod)",
+    personaId: "builtin:fizz",
+  });
+
+  assert.equal(spec.agentPubkey, AGENT_PUBKEY);
+  assert.equal(slug, "fizz-prod");
+  assert.equal(spec.name, "Fizz (prod)");
+  assert.equal(spec.enabled, true);
+});
+
+test("neverPutsASecretOrAPromptOnTheSpec", () => {
+  // A kind:30178 spec is world-readable. Anything secret-shaped here is a leak.
+  const { spec } = buildRelocationPlan({
+    pubkey: AGENT_PUBKEY,
+    name: "Honey",
+    personaId: "builtin:honey",
+  });
+
+  const serialized = JSON.stringify(spec);
+  assert.ok(!/nsec|secret|privateKey|seckey/i.test(serialized));
+  assert.equal(spec.systemPrompt, undefined);
+});
+
+test("publishesThePersonaRelayAddressNotTheRawId", () => {
+  // `builtin:fizz` cannot be a `d` tag — the relay rejects the colon — so a raw
+  // id would point at a persona that cannot exist.
+  const { spec } = buildRelocationPlan({
+    pubkey: AGENT_PUBKEY,
+    name: "Fizz",
+    personaId: "builtin:fizz",
+  });
+
+  assert.equal(spec.personaId, "builtin-fizz");
+});
+
+test("fallsBackToAPlaceholderPromptWhenTheAgentHasNoPersona", () => {
+  // The spec still has to validate; the real prompt arrives over the handshake.
+  const { spec } = buildRelocationPlan({
+    pubkey: AGENT_PUBKEY,
+    name: "Custom",
+    personaId: null,
+  });
+
+  assert.equal(spec.personaId, undefined);
+  assert.equal(typeof spec.systemPrompt, "string");
+});
+
+test("refusesANameThatYieldsNoUsableSlug", () => {
+  // The slug becomes a container and volume name on the host.
+  assert.throws(
+    () => buildRelocationPlan({ pubkey: AGENT_PUBKEY, name: "???" }),
+    /no usable server name/i,
+  );
+});
+
+test("detectsARelocationRegardlessOfHexCasing", () => {
+  assert.equal(
+    isRelocationOfLocalAgent(AGENT_PUBKEY.toUpperCase(), [AGENT_PUBKEY]),
+    true,
+  );
+  assert.equal(isRelocationOfLocalAgent(AGENT_PUBKEY, []), false);
+  assert.equal(isRelocationOfLocalAgent(null, [AGENT_PUBKEY]), false);
+  assert.equal(isRelocationOfLocalAgent(undefined, [AGENT_PUBKEY]), false);
+});
diff --git a/desktop/src/features/agents/agentRelocation.ts b/desktop/src/features/agents/agentRelocation.ts
new file mode 100644
index 0000000000..2466b99caf
--- /dev/null
+++ b/desktop/src/features/agents/agentRelocation.ts
@@ -0,0 +1,79 @@
+import type { SpawnerAgentSpec } from "@/shared/api/spawnerRelay";
+import { personaDTag, slugFromName } from "./spawnerPreference";
+
+/**
+ * The fields of a local agent that a relocation needs.
+ *
+ * Structural rather than `ManagedAgent` so this stays a pure, testable function
+ * of three strings instead of dragging the whole managed-agent shape in.
+ */
+export type RelocatableAgent = {
+  pubkey: string;
+  name: string;
+  personaId?: string | null;
+};
+
+/** A relocation spec, ready to publish at `slug`. */
+export type RelocationPlan = {
+  slug: string;
+  spec: SpawnerAgentSpec;
+};
+
+/**
+ * Build the kind:30178 spec that moves an existing agent onto a spawner.
+ *
+ * What makes this a *relocation* rather than a second agent is `agentPubkey`:
+ * the spawner reads it as "this agent already exists" and asks for its secret
+ * over the encrypted handshake instead of minting a new key. Keeping the key is
+ * the whole point — it carries the agent's channel membership, its profile, its
+ * kind:30177 record (whose `d` tag is the pubkey itself), its DMs, and NIP-AE
+ * memory that a new key could never decrypt.
+ *
+ * Only the public key goes on the spec. Specs are world-readable.
+ *
+ * Throws when the agent's name yields no usable slug, because the slug becomes
+ * a container and volume name on the host and the Rust side rejects anything
+ * outside `[a-z0-9_-]`.
+ */
+export function buildRelocationPlan(agent: RelocatableAgent): RelocationPlan {
+  const slug = slugFromName(agent.name);
+  if (!slug) {
+    throw new Error(
+      `"${agent.name}" has no usable server name. Rename it using letters or digits, then move it.`,
+    );
+  }
+  return {
+    slug,
+    spec: {
+      name: agent.name,
+      agentPubkey: agent.pubkey,
+      // A persona reference, never the prompt: the prompt travels over the
+      // encrypted handshake. Without a persona the spawner still needs
+      // *something* to validate against, so fall back to a placeholder the
+      // handshake overrides.
+      ...(agent.personaId
+        ? { personaId: personaDTag(agent.personaId) }
+        : { systemPrompt: "Server-hosted Buzz agent." }),
+      parallelism: 1,
+      respondTo: "anyone",
+      enabled: true,
+    },
+  };
+}
+
+/**
+ * Whether an attestation request is asking for an agent this device already
+ * runs.
+ *
+ * Pubkeys are compared case-insensitively: hex from the relay and hex from the
+ * local store are the same key regardless of casing, and a mismatch here would
+ * silently downgrade the relocation warning to "a new key was created".
+ */
+export function isRelocationOfLocalAgent(
+  agentPubkey: string | null | undefined,
+  localAgentPubkeys: readonly string[],
+): boolean {
+  if (!agentPubkey) return false;
+  const target = agentPubkey.toLowerCase();
+  return localAgentPubkeys.some((pubkey) => pubkey.toLowerCase() === target);
+}
diff --git a/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs b/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs
index d36d4c7a18..e389e46d8f 100644
--- a/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs
+++ b/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs
@@ -23,6 +23,7 @@ function greenInputs(overrides = {}) {
     workingSource: "none",
     connected: true,
     isLocalBackend: true,
+    isRelocated: false,
     isRunning: true,
     edgeConsumed: false,
     quiescentForMs: AUTO_RESTART_QUIESCENCE_MS,
@@ -56,6 +57,10 @@ const NEVER_FIRE_ROWS = [
   ["observer relay not connected", { connected: false }],
   ["remote backend", { isLocalBackend: false }],
   ["agent not running", { isRunning: false }],
+  [
+    "identity relocated to a spawner (restart here would split-brain)",
+    { isRelocated: true },
+  ],
   [
     "edge already consumed (one attempt per rising edge)",
     { edgeConsumed: true },
diff --git a/desktop/src/features/agents/lib/autoRestartPolicy.ts b/desktop/src/features/agents/lib/autoRestartPolicy.ts
index 2d06a42e39..199a0fd133 100644
--- a/desktop/src/features/agents/lib/autoRestartPolicy.ts
+++ b/desktop/src/features/agents/lib/autoRestartPolicy.ts
@@ -37,6 +37,9 @@ export type AutoRestartInputs = {
   connected: boolean;
   /** Only local agents can be restarted by this loop. */
   isLocalBackend: boolean;
+  /** Identity handed to a spawner (`relocatedToSpawner` set). Restarting the
+   * local copy would run one identity in two places — never fire. */
+  isRelocated: boolean;
   /** Agent process status from the summary ("running" required). */
   isRunning: boolean;
   /** Edge-trigger state: true when this needsRestart rising edge has
@@ -62,6 +65,7 @@ export function decideAutoRestart(
     workingSource,
     connected,
     isLocalBackend,
+    isRelocated,
     isRunning,
     edgeConsumed,
     quiescentForMs,
@@ -71,6 +75,7 @@ export function decideAutoRestart(
   if (!autoRestartEnabled) return "hold";
   if (!needsRestart) return "hold";
   if (!isLocalBackend) return "hold";
+  if (isRelocated) return "hold";
   if (!isRunning) return "hold";
   if (!connected) return "hold";
   // Any working signal — observer OR typing — defers. `working` and
diff --git a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts
index e8e149ccd1..eff2b6edd0 100644
--- a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts
+++ b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts
@@ -72,6 +72,7 @@ export function useAutoRestartPolicy() {
         workingSource: working.source,
         connected: observer.connectionState === "open",
         isLocalBackend: agent.backend.type === "local",
+        isRelocated: agent.relocatedToSpawner !== null,
         isRunning,
         edgeConsumed: edge.consumed,
         quiescentForMs: edge.armedAt === null ? 0 : now - edge.armedAt,
@@ -104,6 +105,7 @@ export function useAutoRestartPolicy() {
           if (
             !current?.needsRestart ||
             !current.autoRestartOnConfigChange ||
+            current.relocatedToSpawner !== null ||
             current.status !== "running" ||
             getAgentWorkingState(agent.pubkey).source !== "none"
           ) {
diff --git a/desktop/src/features/agents/spawnerAttestationStore.ts b/desktop/src/features/agents/spawnerAttestationStore.ts
new file mode 100644
index 0000000000..1c7ae42d81
--- /dev/null
+++ b/desktop/src/features/agents/spawnerAttestationStore.ts
@@ -0,0 +1,325 @@
+import React from "react";
+import { toast } from "sonner";
+
+import { getIdentity } from "@/shared/api/tauriIdentity";
+import {
+  setManagedAgentRelocated,
+  stopManagedAgent,
+} from "@/shared/api/tauriManagedAgents";
+import {
+  respondToSpawnerAttestation,
+  subscribeToSpawnerAttestations,
+} from "@/shared/api/spawnerRelay";
+import {
+  buildSpawnerAttestationResponse,
+  decodeSpawnerAttestation,
+  type SpawnerAttestationRequest,
+  type SpawnerAttestationResponse,
+  type SpawnerPromptMaterial,
+} from "@/shared/api/tauriSpawner";
+import type { RelayEvent } from "@/shared/api/types";
+import { isSpawnerTrusted, trustSpawner } from "./trustedSpawners";
+
+/**
+ * Inbound kind:24201 attestation requests awaiting the owner's decision.
+ *
+ * A module-level singleton rather than React state, matching
+ * `observerRelayStore`: the relay subscription must outlive any one component,
+ * and the queue has to survive navigation so a request that arrives while the
+ * user is elsewhere is not lost.
+ *
+ * # Auto-approval
+ *
+ * A request from an already-trusted spawner is signed without prompting. That
+ * is the whole point of `trustedSpawners` — an owner running five agents on
+ * their own VPS approves the spawner once, not five times. Requests from an
+ * unknown spawner always queue for a human.
+ */
+
+/** A pending request, carrying the ciphertext needed to answer it. */
+export type PendingAttestation = SpawnerAttestationRequest & {
+  /** Original NIP-44 ciphertext, re-decrypted in Rust when responding. */
+  encryptedContent: string;
+  /** Relay event id, used to deduplicate replays on reconnect. */
+  eventId: string;
+};
+
+const listeners = new Set<() => void>();
+
+let pending: readonly PendingAttestation[] = [];
+let unsubscribeRelay: (() => Promise<void>) | null = null;
+let startPromise: Promise<void> | null = null;
+
+/**
+ * Event ids already handled.
+ *
+ * The subscription uses a `since` lookback, so a reconnect can redeliver a
+ * frame that was already answered. Without this the owner would be prompted
+ * twice for one agent, and the second answer would be rejected by the spawner
+ * anyway (its nonce is no longer in flight).
+ */
+const handledEventIds = new Set<string>();
+
+/**
+ * Resolves prompt material for a spec slug at approval time.
+ *
+ * Injected rather than imported so this store stays free of the persona layer.
+ * `useSpawnerIngestion` wires the real resolver; until it does, approvals send
+ * no prompt and a spawner falls back to reading a shared persona.
+ */
+let promptResolver:
+  | ((specSlug: string) => SpawnerPromptMaterial | null)
+  | null = null;
+
+/** Register the prompt resolver used when answering an attestation. */
+export function setSpawnerPromptResolver(
+  resolver: ((specSlug: string) => SpawnerPromptMaterial | null) | null,
+): void {
+  promptResolver = resolver;
+}
+
+function promptFor(specSlug: string): SpawnerPromptMaterial | undefined {
+  return promptResolver?.(specSlug) ?? undefined;
+}
+
+/**
+ * Publish an attestation answer, then retire the local copy of a relocated
+ * agent.
+ *
+ * The order is deliberate and load-bearing:
+ *
+ * 1. **Publish first.** If the response never reaches the spawner the agent is
+ *    still only running here, and stopping it first would take the user's agent
+ *    offline everywhere for nothing.
+ * 2. **Mark relocated second.** Relocation must be *state*, not just a stop:
+ *    the auto-restart policy, app-launch restore, runtime reconcile, and the
+ *    manual Start button all resurrect a merely-stopped agent (the split-brain
+ *    found in testing came from exactly that). The persisted flag is what every
+ *    start path checks, so it lands before the stop it protects.
+ * 3. **Stop third, and only on `relocatedAgentPubkey`.** Rust sets that field
+ *    exactly when it handed the agent's secret key to the spawner, so from that
+ *    moment two processes hold one key. Both would see every mention and both
+ *    would reply: duplicate answers, and the owner billed twice per turn.
+ *
+ * A failed mark or stop is surfaced as a toast rather than swallowed. It leaves
+ * a real split brain that only the user can resolve, so silence is the one
+ * unacceptable outcome — the publish itself already succeeded and must not be
+ * retried.
+ */
+async function publishAndRetireRelocated(
+  response: SpawnerAttestationResponse,
+  spawnerPubkey: string,
+): Promise<void> {
+  await respondToSpawnerAttestation(response.event);
+
+  const relocated = response.relocatedAgentPubkey;
+  if (!relocated) return;
+  try {
+    await setManagedAgentRelocated(relocated, spawnerPubkey);
+    await stopManagedAgent(relocated);
+  } catch (error) {
+    toast.error(
+      `This agent now runs on the server, but the copy on this Mac could not be retired: ${
+        error instanceof Error ? error.message : "unknown error"
+      }. Stop it from the Agents screen — until you do, it will answer twice.`,
+      { duration: Number.POSITIVE_INFINITY },
+    );
+  }
+}
+
+/** Bound on `handledEventIds` so a long session cannot grow it without limit. */
+const MAX_HANDLED_IDS = 500;
+
+const EMPTY: readonly PendingAttestation[] = [];
+
+function notify(): void {
+  for (const listener of listeners) listener();
+}
+
+function markHandled(eventId: string): void {
+  handledEventIds.add(eventId);
+  if (handledEventIds.size > MAX_HANDLED_IDS) {
+    // Insertion-ordered: dropping the oldest keeps the window covering recent
+    // frames, which is all the lookback can redeliver.
+    const oldest = handledEventIds.values().next().value;
+    if (oldest !== undefined) handledEventIds.delete(oldest);
+  }
+}
+
+function removePending(nonce: string): void {
+  const next = pending.filter((item) => item.nonce !== nonce);
+  if (next.length !== pending.length) {
+    pending = next;
+    notify();
+  }
+}
+
+async function handleAttestationEvent(event: RelayEvent): Promise<void> {
+  if (handledEventIds.has(event.id)) return;
+  markHandled(event.id);
+
+  let request: SpawnerAttestationRequest | null;
+  try {
+    request = await decodeSpawnerAttestation(event.pubkey, event.content);
+  } catch {
+    // Not addressed to us, malformed, or not decryptable with our key. Anyone
+    // can publish a p-tagged frame, so this is expected traffic, not an error
+    // worth surfacing.
+    return;
+  }
+  if (!request) return;
+
+  // The verified event author is the spawner. The frame body does not name its
+  // own sender, so there is nothing to cross-check here — Rust decrypts with
+  // this pubkey, meaning a frame that decodes at all came from it.
+  if (request.spawnerPubkey !== event.pubkey) return;
+
+  if (isSpawnerTrusted(request.spawnerPubkey)) {
+    try {
+      await publishAndRetireRelocated(
+        await buildSpawnerAttestationResponse({
+          spawnerPubkey: request.spawnerPubkey,
+          encryptedContent: event.content,
+          trust: "trusted",
+          prompt: promptFor(request.specSlug),
+        }),
+        request.spawnerPubkey,
+      );
+    } catch {
+      // Fall through to prompting. A failed auto-approval (relay down,
+      // signing unavailable) should surface to the user rather than leaving
+      // the agent stuck at pending_attestation with no explanation.
+      enqueue(request, event);
+    }
+    return;
+  }
+
+  enqueue(request, event);
+}
+
+function enqueue(request: SpawnerAttestationRequest, event: RelayEvent): void {
+  // A spawner re-sends with a fresh nonce after a timeout. Replacing the entry
+  // for the same agent keeps one prompt per agent rather than stacking stale
+  // ones the spawner would reject.
+  const withoutSameAgent = pending.filter(
+    (item) => item.agentPubkey !== request.agentPubkey,
+  );
+  pending = [
+    ...withoutSameAgent,
+    { ...request, encryptedContent: event.content, eventId: event.id },
+  ];
+  notify();
+}
+
+/** Approve a pending request, optionally remembering the spawner. */
+export async function approveAttestation(
+  item: PendingAttestation,
+  options: { remember: boolean },
+): Promise<void> {
+  await publishAndRetireRelocated(
+    await buildSpawnerAttestationResponse({
+      spawnerPubkey: item.spawnerPubkey,
+      encryptedContent: item.encryptedContent,
+      trust: "trusted",
+      prompt: promptFor(item.specSlug),
+    }),
+    item.spawnerPubkey,
+  );
+  // Remember only after the signature actually went out, so a failed publish
+  // does not leave a spawner silently trusted for every future request.
+  if (options.remember) trustSpawner(item.spawnerPubkey);
+  removePending(item.nonce);
+}
+
+/**
+ * Decline a pending request.
+ *
+ * Sends an explicit rejection rather than dropping it silently, so the spawner
+ * reports `failed` immediately instead of leaving the agent at
+ * `pending_attestation` until its timeout expires.
+ */
+export async function declineAttestation(
+  item: PendingAttestation,
+  reason?: string,
+): Promise<void> {
+  // Deliberately not `publishAndRetireRelocated`: a rejection hands over no
+  // key, so nothing may stop a local agent on this path.
+  const { event } = await buildSpawnerAttestationResponse({
+    spawnerPubkey: item.spawnerPubkey,
+    encryptedContent: item.encryptedContent,
+    trust: "untrusted",
+    rejectReason: reason,
+  });
+  await respondToSpawnerAttestation(event);
+  removePending(item.nonce);
+}
+
+/** Open the attestation subscription. Idempotent. */
+export async function ensureSpawnerAttestationSubscription(): Promise<void> {
+  if (unsubscribeRelay) return;
+  if (startPromise) return startPromise;
+
+  startPromise = (async () => {
+    const identity = await getIdentity();
+    if (!identity?.pubkey) return;
+    const dispose = await subscribeToSpawnerAttestations(
+      identity.pubkey,
+      (event) => {
+        void handleAttestationEvent(event);
+      },
+    );
+    // A reset that landed while we were connecting must not leave a live
+    // subscription behind for the previous identity.
+    if (startPromise === null) {
+      void dispose();
+      return;
+    }
+    unsubscribeRelay = dispose;
+  })();
+
+  try {
+    await startPromise;
+  } finally {
+    if (unsubscribeRelay) startPromise = null;
+  }
+}
+
+/**
+ * Tear down the subscription and drop queued state.
+ *
+ * Wired into `resetCommunityState()` — pending prompts belong to the community
+ * and identity they arrived under, and showing one after a community switch
+ * would ask the user to sign with the wrong key.
+ */
+export function resetSpawnerAttestationStore(): void {
+  const dispose = unsubscribeRelay;
+  unsubscribeRelay = null;
+  startPromise = null;
+  if (dispose) void dispose();
+
+  handledEventIds.clear();
+  if (pending.length > 0) {
+    pending = [];
+    notify();
+  }
+}
+
+function subscribe(listener: () => void): () => void {
+  listeners.add(listener);
+  return () => {
+    listeners.delete(listener);
+  };
+}
+
+function getSnapshot(): readonly PendingAttestation[] {
+  return pending;
+}
+
+function getServerSnapshot(): readonly PendingAttestation[] {
+  return EMPTY;
+}
+
+/** Reactive view of the pending attestation queue. */
+export function usePendingAttestations(): readonly PendingAttestation[] {
+  return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
diff --git a/desktop/src/features/agents/spawnerDirectoryStore.ts b/desktop/src/features/agents/spawnerDirectoryStore.ts
new file mode 100644
index 0000000000..b18ef6b18b
--- /dev/null
+++ b/desktop/src/features/agents/spawnerDirectoryStore.ts
@@ -0,0 +1,115 @@
+import React from "react";
+
+import {
+  parseSpawnerAnnouncement,
+  subscribeToSpawnerAnnouncements,
+  type SpawnerAnnouncement,
+} from "@/shared/api/spawnerRelay";
+import type { RelayEvent } from "@/shared/api/types";
+
+/**
+ * Spawners that have announced themselves in this community, keyed by pubkey.
+ *
+ * # This is a phone book, not an allowlist
+ *
+ * Anyone can publish a kind:10180. Appearing here means only "some pubkey
+ * claims to be a spawner" — every field is self-reported and unverified.
+ * Connecting is still an explicit user action, and running an agent still
+ * requires the owner to sign a per-agent NIP-OA attestation. Nothing in this
+ * store may be treated as a security property.
+ */
+
+const listeners = new Set<() => void>();
+
+let announcements: ReadonlyMap<string, SpawnerAnnouncement> = new Map();
+let unsubscribeRelay: (() => Promise<void>) | null = null;
+let startPromise: Promise<void> | null = null;
+
+/**
+ * Newest `created_at` per pubkey.
+ *
+ * Kind 10180 is replaceable and the spawner republishes on every reconcile, so
+ * a reconnect can redeliver an older revision after a newer one. Without this
+ * guard a stale capacity count could overwrite the current one.
+ */
+const latestCreatedAt = new Map<string, number>();
+
+const EMPTY: ReadonlyMap<string, SpawnerAnnouncement> = new Map();
+
+function notify(): void {
+  for (const listener of listeners) listener();
+}
+
+function handleAnnouncementEvent(event: RelayEvent): void {
+  const announcement = parseSpawnerAnnouncement(event);
+  if (!announcement) return;
+
+  const previous = latestCreatedAt.get(event.pubkey);
+  if (previous !== undefined && event.created_at <= previous) return;
+  latestCreatedAt.set(event.pubkey, event.created_at);
+
+  const next = new Map(announcements);
+  next.set(event.pubkey, announcement);
+  announcements = next;
+  notify();
+}
+
+/** Open the announcement subscription. Idempotent. */
+export async function ensureSpawnerDirectorySubscription(): Promise<void> {
+  if (unsubscribeRelay) return;
+  if (startPromise) return startPromise;
+
+  startPromise = (async () => {
+    const dispose = await subscribeToSpawnerAnnouncements(
+      handleAnnouncementEvent,
+    );
+    if (startPromise === null) {
+      void dispose();
+      return;
+    }
+    unsubscribeRelay = dispose;
+  })();
+
+  try {
+    await startPromise;
+  } finally {
+    if (unsubscribeRelay) startPromise = null;
+  }
+}
+
+/** Tear down the subscription and drop the directory. */
+export function resetSpawnerDirectoryStore(): void {
+  const dispose = unsubscribeRelay;
+  unsubscribeRelay = null;
+  startPromise = null;
+  if (dispose) void dispose();
+
+  latestCreatedAt.clear();
+  if (announcements.size > 0) {
+    announcements = new Map();
+    notify();
+  }
+}
+
+function subscribe(listener: () => void): () => void {
+  listeners.add(listener);
+  return () => {
+    listeners.delete(listener);
+  };
+}
+
+function getSnapshot(): ReadonlyMap<string, SpawnerAnnouncement> {
+  return announcements;
+}
+
+function getServerSnapshot(): ReadonlyMap<string, SpawnerAnnouncement> {
+  return EMPTY;
+}
+
+/** Reactive view of every announced spawner, keyed by pubkey. */
+export function useSpawnerDirectory(): ReadonlyMap<
+  string,
+  SpawnerAnnouncement
+> {
+  return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
diff --git a/desktop/src/features/agents/spawnerPreference.test.mjs b/desktop/src/features/agents/spawnerPreference.test.mjs
new file mode 100644
index 0000000000..a6956ae6da
--- /dev/null
+++ b/desktop/src/features/agents/spawnerPreference.test.mjs
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { personaDTag, slugFromName } from "./spawnerPreference.ts";
+
+// The Rust side (`check_spec_slug` in buzz-sdk) accepts only lowercase ASCII
+// letters, digits, hyphens, and underscores, must not start with `-` or `_`,
+// and caps at 64 bytes. Slugs reach container names, volume names, and log
+// paths on the spawner host, so anything else is rejected outright. These
+// tests pin the normalization that keeps a user from hitting that wall.
+const ACCEPTED = /^[a-z0-9][a-z0-9_-]*$/;
+
+test("lowercasesAndHyphenatesOrdinaryNames", () => {
+  assert.equal(slugFromName("Fizz"), "fizz");
+  assert.equal(slugFromName("Code Reviewer"), "code-reviewer");
+});
+
+test("stripsCharactersTheSpawnerWouldReject", () => {
+  // "Fizz (prod)" would otherwise fail validation with a message the user
+  // cannot act on.
+  const slug = slugFromName("Fizz (prod)!");
+  assert.equal(slug, "fizz-prod");
+  assert.match(slug, ACCEPTED);
+});
+
+test("neverLeadsWithAHyphenOrUnderscore", () => {
+  // A leading hyphen is explicitly rejected by check_spec_slug.
+  assert.equal(slugFromName("  -Fizz"), "fizz");
+  assert.equal(slugFromName("!!!Fizz"), "fizz");
+  assert.match(slugFromName("-_-Fizz"), ACCEPTED);
+});
+
+test("neverTrailsWithAHyphenEvenAfterTruncation", () => {
+  // Truncating at 64 bytes can land mid-separator and reintroduce a trailing
+  // hyphen after the first strip.
+  const slug = slugFromName(`${"a".repeat(63)} tail`);
+  assert.ok(slug.length <= 64);
+  assert.ok(!slug.endsWith("-"));
+  assert.match(slug, ACCEPTED);
+});
+
+test("respectsTheSixtyFourByteCap", () => {
+  const slug = slugFromName("x".repeat(200));
+  assert.equal(slug.length, 64);
+  assert.match(slug, ACCEPTED);
+});
+
+test("returnsNullWhenNothingUsableSurvives", () => {
+  // Better to refuse with a clear message than to publish an empty d-tag the
+  // spawner cannot address.
+  assert.equal(slugFromName(""), null);
+  assert.equal(slugFromName("   "), null);
+  assert.equal(slugFromName("!!!"), null);
+  assert.equal(slugFromName("日本語"), null);
+});
+
+test("mapsAPersonaIdToItsRelayDTag", () => {
+  // The relay rejects a `d` tag containing a colon, so the built-ins are
+  // published under a normalised slug. A spec carrying the raw id would point
+  // at a persona that cannot exist.
+  assert.equal(personaDTag("builtin:fizz"), "builtin-fizz");
+  assert.equal(personaDTag("builtin:honey"), "builtin-honey");
+  assert.equal(personaDTag("Code Reviewer"), "code-reviewer");
+});
+
+test("personaDTagAlwaysStartsAlphanumericAndFitsTheGrammar", () => {
+  const accepted = /^[a-z0-9][a-z0-9_-]{0,63}$/;
+  assert.match(personaDTag(":leading-colon"), accepted);
+  assert.match(personaDTag("_underscore"), accepted);
+  assert.match(personaDTag("x".repeat(200)), accepted);
+});
diff --git a/desktop/src/features/agents/spawnerPreference.ts b/desktop/src/features/agents/spawnerPreference.ts
new file mode 100644
index 0000000000..d49c366d7f
--- /dev/null
+++ b/desktop/src/features/agents/spawnerPreference.ts
@@ -0,0 +1,171 @@
+import React from "react";
+
+/**
+ * The spawners this device deploys server-hosted agents to.
+ *
+ * A list, not a single value: an owner may keep a beefy box for agents that
+ * need it and a cheap VPS for the rest, and each agent picks where it runs.
+ *
+ * Local-only, like [`trustedSpawners`](./trustedSpawners.ts). Which servers you
+ * deploy to is a per-device operational choice — a laptop on a VPN may reach a
+ * spawner a phone cannot.
+ *
+ * Only pubkeys are stored. A spawner is addressed entirely through the relay
+ * (specs carry a `spawner` tag, status comes back `#p`-tagged), so it can run on
+ * any host, behind NAT, anywhere with outbound WebSocket — there is no URL for a
+ * client to know, cache, or get wrong.
+ */
+const STORAGE_KEY = "buzz:spawner-pubkeys";
+
+/** Pre-list key, read once so an existing single-spawner setup carries over. */
+const LEGACY_STORAGE_KEY = "buzz:spawner-pubkey";
+
+const listeners = new Set<() => void>();
+
+/// Declared before `spawners` because `readStored()` returns it: the reverse
+/// order is a temporal-dead-zone crash at module load, which is exactly what
+/// happens on a device with nothing stored yet.
+const EMPTY: readonly string[] = [];
+
+let spawners: readonly string[] = readStored();
+
+function isPubkeyHex(value: string): boolean {
+  return value.length === 64 && /^[0-9a-f]+$/i.test(value);
+}
+
+function readStored(): readonly string[] {
+  try {
+    const raw = window.localStorage.getItem(STORAGE_KEY);
+    if (raw) {
+      const parsed: unknown = JSON.parse(raw);
+      if (Array.isArray(parsed)) {
+        return dedupe(
+          parsed.filter(
+            (v): v is string => typeof v === "string" && isPubkeyHex(v),
+          ),
+        );
+      }
+      return EMPTY;
+    }
+    // Migrate the single-value key rather than silently dropping a spawner the
+    // user already connected to.
+    const legacy = window.localStorage.getItem(LEGACY_STORAGE_KEY);
+    if (legacy && isPubkeyHex(legacy)) {
+      const migrated = [legacy.toLowerCase()];
+      window.localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated));
+      window.localStorage.removeItem(LEGACY_STORAGE_KEY);
+      return migrated;
+    }
+    return EMPTY;
+  } catch {
+    return EMPTY;
+  }
+}
+
+function dedupe(values: string[]): readonly string[] {
+  return [...new Set(values.map((v) => v.toLowerCase()))];
+}
+
+function persist(next: readonly string[]): void {
+  spawners = next;
+  try {
+    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+  } catch {
+    // Keep the in-memory value so this session still works.
+  }
+  for (const listener of listeners) listener();
+}
+
+/**
+ * Connect this device to a spawner.
+ *
+ * Returns false when the value is not a 64-character hex pubkey, so callers can
+ * show a validation message rather than silently storing nothing.
+ */
+export function addSpawner(pubkey: string): boolean {
+  if (!isPubkeyHex(pubkey)) return false;
+  const normalized = pubkey.toLowerCase();
+  if (spawners.includes(normalized)) return true;
+  persist([...spawners, normalized]);
+  return true;
+}
+
+/**
+ * Disconnect this device from a spawner.
+ *
+ * Local only: it stops this device managing that spawner's agents. Agents
+ * already deployed keep running, because their specs still live on the relay —
+ * removing them means deleting those specs.
+ */
+export function removeSpawner(pubkey: string): void {
+  const normalized = pubkey.toLowerCase();
+  if (!spawners.includes(normalized)) return;
+  persist(spawners.filter((s) => s !== normalized));
+}
+
+/** Every connected spawner pubkey. */
+export function getSpawners(): readonly string[] {
+  return spawners;
+}
+
+function subscribe(listener: () => void): () => void {
+  listeners.add(listener);
+  return () => {
+    listeners.delete(listener);
+  };
+}
+
+function getSnapshot(): readonly string[] {
+  return spawners;
+}
+
+function getServerSnapshot(): readonly string[] {
+  return EMPTY;
+}
+
+/** Reactive view of the connected spawners. */
+export function useSpawners(): readonly string[] {
+  return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+/**
+ * Derive a spec slug from a display name.
+ *
+ * Slugs travel into container names, volume names, and log paths on the spawner
+ * host, so the Rust side accepts only lowercase alphanumerics, hyphens, and
+ * underscores and rejects anything else. Normalizing here means a user typing
+ * "Fizz (prod)" gets `fizz-prod` instead of a validation error they cannot act
+ * on. Returns null when nothing usable survives.
+ */
+export function slugFromName(name: string): string | null {
+  const slug = name
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, "-")
+    .replace(/^[-_]+/, "")
+    .replace(/-+$/, "")
+    .slice(0, 64)
+    // A trailing hyphen can reappear after the 64-byte truncation.
+    .replace(/-+$/, "");
+  return slug.length > 0 ? slug : null;
+}
+
+/**
+ * Normalize a persona id to the `d` tag its kind:30175 event is published under.
+ *
+ * Mirrors `persona_d_tag` / `normalize_d_tag` in
+ * `desktop/src-tauri/src/managed_agents/persona_events.rs`, which enforces the
+ * NIP-AP grammar `^[a-z0-9][a-z0-9_-]{0,63}$`. This matters because a persona's
+ * *id* and its *address on the relay* are not the same string: the built-in
+ * `builtin:fizz` is published as `builtin-fizz`, since the relay rejects a `d`
+ * tag containing a colon. A spec that referenced the raw id would point at a
+ * persona that cannot exist, and the spawner would fail to resolve the prompt.
+ */
+export function personaDTag(personaId: string): string {
+  let out = "";
+  for (const ch of personaId.toLowerCase()) {
+    out += /[a-z0-9_-]/.test(ch) ? ch : "-";
+  }
+  if (!/^[a-z0-9]/.test(out)) out = `a${out}`;
+  return out.slice(0, 64);
+}
diff --git a/desktop/src/features/agents/spawnerStatusStore.ts b/desktop/src/features/agents/spawnerStatusStore.ts
new file mode 100644
index 0000000000..ce8a08cb96
--- /dev/null
+++ b/desktop/src/features/agents/spawnerStatusStore.ts
@@ -0,0 +1,152 @@
+import React from "react";
+
+import { getIdentity } from "@/shared/api/tauriIdentity";
+import {
+  parseSpawnerStatus,
+  specSlugFromEvent,
+  subscribeToSpawnerStatus,
+  type SpawnerAgentStatus,
+} from "@/shared/api/spawnerRelay";
+import type { RelayEvent } from "@/shared/api/types";
+
+/**
+ * Live status for this owner's server-hosted agents, keyed by
+ * `"<spawnerPubkey>/<slug>"`.
+ *
+ * # Why the key includes the spawner
+ *
+ * Kind 30179 is NIP-33 addressed by `(pubkey, kind, d_tag)`, so the *author* is
+ * part of the identity of a status document. Keying on the slug alone would let
+ * any pubkey that publishes a 30179 with a matching slug overwrite the real
+ * spawner's status in this map — the relay correctly stores them at separate
+ * addresses, and collapsing them here would undo that. Callers look up the
+ * spawner they addressed the spec to.
+ */
+
+const listeners = new Set<() => void>();
+
+let statuses: ReadonlyMap<string, SpawnerAgentStatus> = new Map();
+let unsubscribeRelay: (() => Promise<void>) | null = null;
+let startPromise: Promise<void> | null = null;
+
+/**
+ * Newest `created_at` seen per key.
+ *
+ * A reconnect can replay an older revision after a newer one has landed.
+ * Without this guard a stale `pending_attestation` could clobber a live
+ * `running`, and the UI would show an agent as stuck when it is fine.
+ */
+const latestCreatedAt = new Map<string, number>();
+
+const EMPTY: ReadonlyMap<string, SpawnerAgentStatus> = new Map();
+
+/** Compose the lookup key for a status entry. */
+export function spawnerStatusKey(spawnerPubkey: string, slug: string): string {
+  return `${spawnerPubkey}/${slug}`;
+}
+
+function notify(): void {
+  for (const listener of listeners) listener();
+}
+
+function handleStatusEvent(event: RelayEvent): void {
+  const slug = specSlugFromEvent(event);
+  if (!slug) return;
+
+  const key = spawnerStatusKey(event.pubkey, slug);
+  const previous = latestCreatedAt.get(key);
+  if (previous !== undefined && event.created_at <= previous) return;
+
+  // An emptied replacement is the spawner's tombstone for a deleted agent.
+  // Without honouring it the last real status — often `pending_attestation` —
+  // persists forever and the UI shows a row for an agent that no longer
+  // exists, offering buttons that act on nothing.
+  const isTombstone = event.content.trim().length === 0;
+  const status = isTombstone ? null : parseSpawnerStatus(event.content);
+  // Unparseable content is not a tombstone: dropping a live agent because one
+  // malformed event arrived would be worse than ignoring the event.
+  if (!isTombstone && !status) return;
+
+  latestCreatedAt.set(key, event.created_at);
+
+  const next = new Map(statuses);
+  if (status) {
+    next.set(key, status);
+  } else if (!next.delete(key)) {
+    // Nothing was showing for this agent, so the tombstone changes nothing.
+    return;
+  }
+  statuses = next;
+  notify();
+}
+
+/** Open the status subscription. Idempotent. */
+export async function ensureSpawnerStatusSubscription(): Promise<void> {
+  if (unsubscribeRelay) return;
+  if (startPromise) return startPromise;
+
+  startPromise = (async () => {
+    const identity = await getIdentity();
+    if (!identity?.pubkey) return;
+    const dispose = await subscribeToSpawnerStatus(
+      identity.pubkey,
+      handleStatusEvent,
+    );
+    // A reset during connect must not strand a subscription for the old identity.
+    if (startPromise === null) {
+      void dispose();
+      return;
+    }
+    unsubscribeRelay = dispose;
+  })();
+
+  try {
+    await startPromise;
+  } finally {
+    if (unsubscribeRelay) startPromise = null;
+  }
+}
+
+/** Tear down the subscription and drop cached status. */
+export function resetSpawnerStatusStore(): void {
+  const dispose = unsubscribeRelay;
+  unsubscribeRelay = null;
+  startPromise = null;
+  if (dispose) void dispose();
+
+  latestCreatedAt.clear();
+  if (statuses.size > 0) {
+    statuses = new Map();
+    notify();
+  }
+}
+
+function subscribe(listener: () => void): () => void {
+  listeners.add(listener);
+  return () => {
+    listeners.delete(listener);
+  };
+}
+
+function getSnapshot(): ReadonlyMap<string, SpawnerAgentStatus> {
+  return statuses;
+}
+
+function getServerSnapshot(): ReadonlyMap<string, SpawnerAgentStatus> {
+  return EMPTY;
+}
+
+/** Reactive view of every known server-agent status. */
+export function useSpawnerStatuses(): ReadonlyMap<string, SpawnerAgentStatus> {
+  return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+/** Reactive status for one server agent, or undefined if none has arrived. */
+export function useSpawnerStatus(
+  spawnerPubkey: string | undefined,
+  slug: string | undefined,
+): SpawnerAgentStatus | undefined {
+  const statuses = useSpawnerStatuses();
+  if (!spawnerPubkey || !slug) return undefined;
+  return statuses.get(spawnerStatusKey(spawnerPubkey, slug));
+}
diff --git a/desktop/src/features/agents/trustedSpawners.ts b/desktop/src/features/agents/trustedSpawners.ts
new file mode 100644
index 0000000000..56c8e56738
--- /dev/null
+++ b/desktop/src/features/agents/trustedSpawners.ts
@@ -0,0 +1,115 @@
+import React from "react";
+
+/**
+ * The set of spawner pubkeys this user has approved.
+ *
+ * # Why this exists
+ *
+ * Signing a NIP-OA attestation admits an agent pubkey to the relay under *this
+ * user's* membership. A spawner that obtains a signature can run an agent that
+ * reads the user's channels. So the first request from any spawner must be an
+ * explicit, human decision — auto-signing for an unknown pubkey would turn a
+ * single malicious kind:24201 frame into silent access.
+ *
+ * Trust is remembered per spawner so an owner running several agents on their
+ * own VPS is prompted once, not once per agent.
+ *
+ * Deliberately local-only, unlike most Buzz preferences which roam via NIP-78
+ * kind:30078. Syncing this would mean approving a spawner on a phone silently
+ * authorizes it on a laptop, which is the opposite of what a trust decision
+ * should do — each device should vouch for itself.
+ */
+const STORAGE_KEY = "buzz:trusted-spawners";
+
+const listeners = new Set<() => void>();
+
+let trusted: ReadonlySet<string> = readStored();
+
+const EMPTY: ReadonlySet<string> = new Set<string>();
+
+function readStored(): ReadonlySet<string> {
+  try {
+    const raw = window.localStorage.getItem(STORAGE_KEY);
+    if (!raw) return new Set<string>();
+    const parsed: unknown = JSON.parse(raw);
+    if (!Array.isArray(parsed)) return new Set<string>();
+    return new Set(
+      parsed.filter(
+        (value): value is string =>
+          typeof value === "string" && isPubkeyHex(value),
+      ),
+    );
+  } catch {
+    return new Set<string>();
+  }
+}
+
+function persist(next: ReadonlySet<string>): void {
+  trusted = next;
+  try {
+    window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]));
+  } catch {
+    // A full or unavailable localStorage must not break the prompt. The
+    // decision still applies to this request; the user is asked again next
+    // time, which fails toward asking rather than toward trusting.
+  }
+  for (const listener of listeners) listener();
+}
+
+function isPubkeyHex(value: string): boolean {
+  return value.length === 64 && /^[0-9a-f]+$/i.test(value);
+}
+
+/** Whether the user has approved this spawner. */
+export function isSpawnerTrusted(spawnerPubkey: string): boolean {
+  return trusted.has(spawnerPubkey.toLowerCase());
+}
+
+/** Record approval for a spawner. */
+export function trustSpawner(spawnerPubkey: string): void {
+  const normalized = spawnerPubkey.toLowerCase();
+  if (!isPubkeyHex(normalized) || trusted.has(normalized)) return;
+  persist(new Set([...trusted, normalized]));
+}
+
+/**
+ * Revoke approval for a spawner.
+ *
+ * Revoking does not retract attestations already signed — a NIP-OA tag, once
+ * issued, is valid until the agent's identity is archived. It only stops future
+ * requests from being auto-approved. Tearing down the agents themselves means
+ * deleting their specs.
+ */
+export function revokeSpawner(spawnerPubkey: string): void {
+  const normalized = spawnerPubkey.toLowerCase();
+  if (!trusted.has(normalized)) return;
+  const next = new Set(trusted);
+  next.delete(normalized);
+  persist(next);
+}
+
+/** Clear every trust decision. Used when switching identities. */
+export function resetTrustedSpawners(): void {
+  if (trusted.size === 0) return;
+  persist(new Set<string>());
+}
+
+function subscribe(listener: () => void): () => void {
+  listeners.add(listener);
+  return () => {
+    listeners.delete(listener);
+  };
+}
+
+function getSnapshot(): ReadonlySet<string> {
+  return trusted;
+}
+
+function getServerSnapshot(): ReadonlySet<string> {
+  return EMPTY;
+}
+
+/** Reactive view of the approved spawner set. */
+export function useTrustedSpawners(): ReadonlySet<string> {
+  return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
diff --git a/desktop/src/features/agents/ui/AgentDialog.tsx b/desktop/src/features/agents/ui/AgentDialog.tsx
index f5be3cc7e8..e5ca1195ec 100644
--- a/desktop/src/features/agents/ui/AgentDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentDialog.tsx
@@ -15,12 +15,17 @@ import {
   AgentDefinitionDialog,
   type AgentDefinitionSubmitOptions,
 } from "./AgentDefinitionDialog";
+import { AgentRunsOnSection } from "./AgentRunsOnSection";
 import { WhereToRunSection } from "./WhereToRunSection";
 import {
   canSubmitWhereToRun,
   emptyWhereToRunDraft,
   resolveBackendIntent,
 } from "./whereToRunIntent";
+import {
+  useDefaultAgentLocation,
+  type AgentLocation,
+} from "@/features/agents/agentLocation";
 
 type AgentDialogCreateProps = {
   mode: "definition";
@@ -34,6 +39,7 @@ type AgentDialogCreateProps = {
     input: CreatePersonaInput | UpdatePersonaInput,
     intent: AgentCreateIntent,
     backendIntent: BackendIntent | null,
+    location: AgentLocation,
   ) => Promise<boolean>;
 };
 
@@ -116,6 +122,15 @@ function AgentCreateDialogRouter({
   onSubmitDefinition,
 }: AgentDialogCreateProps) {
   const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft);
+  // Until the user picks, the dialog *inherits* the stored default rather than
+  // snapshotting it — so a spawner that connects (or the default changing)
+  // while the dialog is open is still reflected, and the built-ins that carry
+  // no location of their own follow it too.
+  const [chosenLocation, setChosenLocation] =
+    React.useState<AgentLocation | null>(null);
+  const defaultLocation = useDefaultAgentLocation();
+  const location = chosenLocation ?? defaultLocation;
+  const isLocal = location.kind === "local";
   const initialValues = React.useMemo(
     () => providedInitialValues ?? createPersonaDialogState().initialValues,
     [providedInitialValues],
@@ -126,13 +141,24 @@ function AgentCreateDialogRouter({
   return (
     <AgentDefinitionDialog
       createRunSection={
-        <WhereToRunSection
-          draft={runDraft}
-          isPending={isDefinitionPending}
-          onDraftChange={setRunDraft}
-        />
+        <div className="space-y-4">
+          <AgentRunsOnSection
+            isPending={isDefinitionPending}
+            location={location}
+            onLocationChange={setChosenLocation}
+          />
+          {/* A local backend provider only has meaning for a local agent: on a
+              spawner the host owns the process and its key. */}
+          {isLocal ? (
+            <WhereToRunSection
+              draft={runDraft}
+              isPending={isDefinitionPending}
+              onDraftChange={setRunDraft}
+            />
+          ) : null}
+        </div>
       }
-      createSubmitBlocked={!canSubmitWhereToRun(runDraft)}
+      createSubmitBlocked={isLocal && !canSubmitWhereToRun(runDraft)}
       description={copy.description}
       error={definitionError}
       initialValues={initialValues}
@@ -142,7 +168,8 @@ function AgentCreateDialogRouter({
         const submitted = await onSubmitDefinition(
           input,
           "definition_start",
-          resolveBackendIntent(runDraft),
+          isLocal ? resolveBackendIntent(runDraft) : null,
+          location,
         );
         if (submitted) {
           onOpenChange(false);
diff --git a/desktop/src/features/agents/ui/AgentRunsOnSection.tsx b/desktop/src/features/agents/ui/AgentRunsOnSection.tsx
new file mode 100644
index 0000000000..5d58b61546
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentRunsOnSection.tsx
@@ -0,0 +1,71 @@
+import * as React from "react";
+
+import type { AgentLocation } from "../agentLocation";
+import { useSpawnerDirectory } from "../spawnerDirectoryStore";
+import { useSpawners } from "../spawnerPreference";
+import {
+  agentLocationOptions,
+  agentLocationValue,
+  parseAgentLocationValue,
+} from "./agentLocationOptions";
+import { runtimeLabel, spawnerLabel } from "./ServerAgentsSection";
+
+/**
+ * "Runs on" — which machine this agent lives on.
+ *
+ * Deliberately separate from the harness picker: a runtime says which binary
+ * drives the agent, a location says whose computer runs it. Renders nothing
+ * unless a spawner is connected, since "this Mac" is then the only answer.
+ */
+export function AgentRunsOnSection({
+  isPending,
+  location,
+  onLocationChange,
+}: {
+  isPending: boolean;
+  location: AgentLocation;
+  onLocationChange: (next: AgentLocation) => void;
+}) {
+  const spawners = useSpawners();
+  const directory = useSpawnerDirectory();
+  const options = React.useMemo(
+    () =>
+      agentLocationOptions(spawners, (pubkey) => ({
+        label: spawnerLabel(pubkey, directory),
+        hint: runtimeLabel(directory.get(pubkey)?.runtime),
+      })),
+    [directory, spawners],
+  );
+
+  if (options.length === 0) return null;
+
+  return (
+    <div className="space-y-1.5">
+      <label className="text-sm font-medium" htmlFor="agent-runs-on">
+        Runs on
+      </label>
+      <select
+        className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
+        disabled={isPending}
+        id="agent-runs-on"
+        onChange={(event) =>
+          onLocationChange(
+            parseAgentLocationValue(event.target.value, spawners),
+          )
+        }
+        value={agentLocationValue(location)}
+      >
+        {options.map((option) => (
+          <option key={option.value} value={option.value}>
+            {option.hint ? `${option.label} · ${option.hint}` : option.label}
+          </option>
+        ))}
+      </select>
+      <p className="text-2xs text-muted-foreground">
+        {location.kind === "local"
+          ? "This agent stops when Buzz is closed."
+          : "The server runs this agent, so it keeps working when Buzz is closed. Approve its key when prompted."}
+      </p>
+    </div>
+  );
+}
diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx
index 6f34ffba79..b3a4019a40 100644
--- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx
+++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx
@@ -1,4 +1,4 @@
-import { CircleAlert, Play } from "lucide-react";
+import { CircleAlert, Play, Server } from "lucide-react";
 import { useReducedMotion } from "motion/react";
 
 import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
@@ -18,6 +18,9 @@ type AgentRuntimeAvatarControlProps = {
   errorLabel?: string | null;
   errorTestId?: string;
   isActive: boolean;
+  /** Identity handed to a spawner: show "runs on a server" instead of Start.
+   * Starting locally would run one identity in two places. */
+  isRelocated?: boolean;
   isStarting: boolean;
   label: string;
   startTestId: string;
@@ -101,6 +104,7 @@ export function AgentRuntimeAvatarControl({
   errorLabel,
   errorTestId,
   isActive,
+  isRelocated = false,
   isStarting,
   label,
   startTestId,
@@ -119,7 +123,17 @@ export function AgentRuntimeAvatarControl({
     <MaskedAvatarBadgeFrame
       badge={
         <span className="grid h-full w-full place-items-center">
-          {isActive ? (
+          {!isActive && isRelocated ? (
+            <span
+              aria-label={`${label} runs on a server`}
+              className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-muted-foreground"
+              data-testid={`${startTestId}-relocated`}
+              role="img"
+              title={`${label} runs on a server and cannot be started on this Mac.`}
+            >
+              <Server className="h-4 w-4" />
+            </span>
+          ) : isActive ? (
             <span
               aria-label={`${label} is running`}
               className="flex h-6 w-6 items-center justify-center rounded-full"
diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx
index f24a3c06d7..d2b04095ac 100644
--- a/desktop/src/features/agents/ui/AgentsView.tsx
+++ b/desktop/src/features/agents/ui/AgentsView.tsx
@@ -16,6 +16,7 @@ import { AgentSnapshotImportDialog } from "./AgentSnapshotImportDialog";
 import { TeamSnapshotExportDialog } from "./TeamSnapshotExportDialog";
 import { TeamSnapshotImportDialog } from "./TeamSnapshotImportDialog";
 import { TeamShareDialog } from "./TeamShareDialog";
+import { ServerAgentsSection } from "./ServerAgentsSection";
 import { SecretRevealDialog } from "./SecretRevealDialog";
 import { TeamDeleteDialog } from "./TeamDeleteDialog";
 import { TeamDialog } from "./TeamDialog";
@@ -233,6 +234,8 @@ export function AgentsView() {
               personas={personas.libraryPersonas}
               teams={teamActions.teams}
             />
+
+            <ServerAgentsSection personas={personas.libraryPersonas} />
           </div>
         </div>
       </div>
diff --git a/desktop/src/features/agents/ui/MoveAgentToServerMenu.tsx b/desktop/src/features/agents/ui/MoveAgentToServerMenu.tsx
new file mode 100644
index 0000000000..d5af834b87
--- /dev/null
+++ b/desktop/src/features/agents/ui/MoveAgentToServerMenu.tsx
@@ -0,0 +1,73 @@
+import { ServerCog } from "lucide-react";
+import { toast } from "sonner";
+
+import type { ManagedAgent } from "@/shared/api/types";
+import {
+  DropdownMenuItem,
+  DropdownMenuSub,
+  DropdownMenuSubContent,
+  DropdownMenuSubTrigger,
+} from "@/shared/ui/dropdown-menu";
+import { useSpawnerDirectory } from "../spawnerDirectoryStore";
+import { useServerAgents } from "../useServerAgents";
+import { spawnerLabel } from "./ServerAgentsSection";
+
+/**
+ * "Move to server" for an agent that already exists on this Mac.
+ *
+ * Deliberately *not* the Deploy menu: deploying a persona mints a new key and
+ * leaves the local agent alone, while this keeps the agent's existing key and
+ * retires the local copy. Same agent, different machine — which is the only way
+ * its channel membership, profile, DMs, and NIP-AE memory survive the move.
+ *
+ * Renders nothing when there is no local agent to move or no spawner to move it
+ * to; a disabled item for an action that cannot exist yet is just noise.
+ */
+export function MoveAgentToServerMenu({
+  agent,
+  disabled,
+}: {
+  agent: ManagedAgent | undefined;
+  disabled: boolean;
+}) {
+  const { spawners, relocate, isPending } = useServerAgents();
+  const directory = useSpawnerDirectory();
+
+  if (!agent || spawners.length === 0) return null;
+
+  const handleMove = async (spawnerPubkey: string) => {
+    try {
+      await relocate(agent, spawnerPubkey);
+      toast.success(
+        `Moving ${agent.name} to ${spawnerLabel(spawnerPubkey, directory)}. ` +
+          `Approve the key when prompted — it keeps its identity and stops running on this Mac.`,
+      );
+    } catch (error) {
+      toast.error(
+        error instanceof Error
+          ? error.message
+          : `Failed to move ${agent.name} to the server.`,
+      );
+    }
+  };
+
+  return (
+    <DropdownMenuSub>
+      <DropdownMenuSubTrigger disabled={disabled || isPending}>
+        <ServerCog className="h-4 w-4" />
+        Move to server
+      </DropdownMenuSubTrigger>
+      <DropdownMenuSubContent>
+        {spawners.map((spawnerPubkey) => (
+          <DropdownMenuItem
+            disabled={disabled || isPending}
+            key={spawnerPubkey}
+            onSelect={() => void handleMove(spawnerPubkey)}
+          >
+            {spawnerLabel(spawnerPubkey, directory)}
+          </DropdownMenuItem>
+        ))}
+      </DropdownMenuSubContent>
+    </DropdownMenuSub>
+  );
+}
diff --git a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx
index 30bb7fff6e..4da1e06db9 100644
--- a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx
+++ b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx
@@ -14,6 +14,7 @@ import {
   DropdownMenuSeparator,
   DropdownMenuTrigger,
 } from "@/shared/ui/dropdown-menu";
+import { MoveAgentToServerMenu } from "./MoveAgentToServerMenu";
 
 export function PersonaActionsMenu({
   isActionPending,
@@ -78,6 +79,7 @@ export function PersonaActionsMenu({
           <Share2 className="h-4 w-4" />
           Share
         </DropdownMenuItem>
+        <MoveAgentToServerMenu agent={linkedAgent} disabled={disabled} />
         <DropdownMenuSeparator />
         {persona.sourceTeam ? (
           <DropdownMenuItem disabled>
diff --git a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx
index ee800b5cb5..d5c86b2cc1 100644
--- a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx
+++ b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx
@@ -51,8 +51,14 @@ export function RequestedAgentCreateDialogs() {
               setTargetChannel(null);
             }
           }}
-          onSubmitDefinition={(input, intent, backendIntent) =>
-            personas.handleSubmit(input, intent, backendIntent, targetChannel)
+          onSubmitDefinition={(input, intent, backendIntent, location) =>
+            personas.handleSubmit(
+              input,
+              intent,
+              backendIntent,
+              location,
+              targetChannel,
+            )
           }
           runtimes={personas.acpRuntimesQuery.data ?? []}
           runtimesLoading={personas.acpRuntimesQuery.isLoading}
diff --git a/desktop/src/features/agents/ui/ServerAgentsSection.tsx b/desktop/src/features/agents/ui/ServerAgentsSection.tsx
new file mode 100644
index 0000000000..605aedc108
--- /dev/null
+++ b/desktop/src/features/agents/ui/ServerAgentsSection.tsx
@@ -0,0 +1,493 @@
+import { Check, CircleAlert, Play, Square, Trash2 } from "lucide-react";
+import React from "react";
+import { toast } from "sonner";
+
+import type {
+  SpawnerAnnouncement,
+  SpawnPhase,
+} from "@/shared/api/spawnerRelay";
+import type { AgentPersona } from "@/shared/api/types";
+import { Badge } from "@/shared/ui/badge";
+import { Button } from "@/shared/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuLabel,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from "@/shared/ui/dropdown-menu";
+import { Input } from "@/shared/ui/input";
+import { SectionHeader } from "@/shared/ui/PageHeader";
+import {
+  sameLocation,
+  setDefaultAgentLocation,
+  useDefaultAgentLocation,
+} from "../agentLocation";
+import { useSpawnerDirectory } from "../spawnerDirectoryStore";
+import { addSpawner, removeSpawner } from "../spawnerPreference";
+import { useServerAgents, type ServerAgent } from "../useServerAgents";
+import { shortenPubkey } from "./SpawnerAttestationDialog";
+
+/** Human-readable label and tone for a reconciliation phase. */
+export function phaseLabel(phase: SpawnPhase): {
+  label: string;
+  variant: "secondary" | "destructive" | "warning" | "success" | "info";
+} {
+  switch (phase) {
+    case "running":
+      return { label: "Running", variant: "success" };
+    case "starting":
+      return { label: "Starting", variant: "info" };
+    case "pending_attestation":
+      // Warning, not neutral: this state needs the user to do something, and
+      // the agent stays dead until they do.
+      return { label: "Awaiting approval", variant: "warning" };
+    case "stopped":
+      return { label: "Stopped", variant: "secondary" };
+    case "failed":
+      return { label: "Failed", variant: "destructive" };
+  }
+}
+
+/**
+ * Friendly name for an advertised agent runtime.
+ *
+ * Purely cosmetic: the value is self-reported by the host and controls nothing.
+ * Unknown values pass through so a spawner running something we have never heard
+ * of still reads sensibly.
+ */
+export function runtimeLabel(runtime: string | undefined): string | undefined {
+  if (!runtime) return undefined;
+  switch (runtime) {
+    case "claude-agent-acp":
+    case "claude-code-acp":
+      return "Claude Code";
+    case "buzz-agent":
+      return "Buzz agent";
+    case "goose":
+      return "goose";
+    case "codex-acp":
+      return "Codex";
+    default:
+      return runtime;
+  }
+}
+
+/** Display label for a spawner, preferring its announced name. */
+export function spawnerLabel(
+  pubkey: string,
+  directory: ReadonlyMap<string, SpawnerAnnouncement>,
+): string {
+  return directory.get(pubkey)?.name ?? shortenPubkey(pubkey);
+}
+
+type ServerAgentsSectionProps = {
+  personas: AgentPersona[];
+};
+
+/**
+ * Server-hosted agents: the ones that keep running when this app is closed.
+ *
+ * Supports several spawners at once — a GPU box for agents that need it and a
+ * cheap VPS for the rest — because a spawner is addressed by pubkey through the
+ * relay and can live anywhere with outbound WebSocket.
+ */
+export function ServerAgentsSection({ personas }: ServerAgentsSectionProps) {
+  const {
+    spawners,
+    agents,
+    isPending,
+    create,
+    setEnabled,
+    remove,
+    hasServerAgent,
+  } = useServerAgents();
+  const directory = useSpawnerDirectory();
+
+  const personaByName = React.useMemo(() => {
+    const map = new Map<string, AgentPersona>();
+    for (const persona of personas) map.set(persona.displayName, persona);
+    return map;
+  }, [personas]);
+
+  // Announced spawners this device has not connected to yet — the discovery
+  // path that replaces pasting 64 hex characters.
+  const undiscovered = React.useMemo(
+    () => [...directory.values()].filter((a) => !spawners.includes(a.pubkey)),
+    [directory, spawners],
+  );
+
+  const handleDeploy = async (persona: AgentPersona, spawner: string) => {
+    try {
+      await create(persona, spawner);
+      toast.success(
+        `Deploying ${persona.displayName} to ${spawnerLabel(spawner, directory)}. Approve the key when prompted.`,
+      );
+    } catch (error) {
+      toast.error(errorMessage(error, "Failed to deploy the agent."));
+    }
+  };
+
+  return (
+    <section className="space-y-3">
+      <SectionHeader
+        action={
+          spawners.length > 0 ? (
+            <DeployMenu
+              directory={directory}
+              disabled={isPending}
+              hasServerAgent={hasServerAgent}
+              onDeploy={handleDeploy}
+              personas={personas}
+              spawners={spawners}
+            />
+          ) : undefined
+        }
+        title="Server agents"
+      />
+
+      {spawners.length === 0 ? (
+        <p className="text-sm text-muted-foreground">
+          Run agents on a server so they keep working when Buzz is closed. A
+          spawner can live anywhere that can reach this relay — it does not have
+          to be the relay machine.
+        </p>
+      ) : null}
+
+      {undiscovered.length > 0 ? (
+        <DiscoveredSpawners spawners={undiscovered} />
+      ) : null}
+
+      {spawners.map((spawner) => {
+        const spawnerAgents = agents.filter((a) => a.spawnerPubkey === spawner);
+        return (
+          <div className="space-y-2" key={spawner}>
+            <div className="flex items-baseline justify-between gap-2">
+              <p className="text-sm font-medium">
+                {spawnerLabel(spawner, directory)}{" "}
+                <span className="text-2xs font-normal text-muted-foreground">
+                  {runtimeLabel(directory.get(spawner)?.runtime) ??
+                    shortenPubkey(spawner)}
+                </span>
+              </p>
+              <div className="flex shrink-0 items-center gap-3">
+                <DefaultLocationToggle spawner={spawner} />
+                <button
+                  className="text-xs text-muted-foreground underline underline-offset-2"
+                  onClick={() => removeSpawner(spawner)}
+                  type="button"
+                >
+                  Disconnect
+                </button>
+              </div>
+            </div>
+            {spawnerAgents.length === 0 ? (
+              <p className="text-sm text-muted-foreground">
+                No agents here yet. Use "Deploy agent".
+              </p>
+            ) : (
+              <ul className="space-y-2">
+                {spawnerAgents.map((agent) => (
+                  <ServerAgentRow
+                    agent={agent}
+                    isPending={isPending}
+                    key={`${agent.spawnerPubkey}/${agent.slug}`}
+                    onRemove={async () => {
+                      try {
+                        await remove(agent);
+                        toast.success(`Removed "${agent.slug}".`);
+                      } catch (error) {
+                        toast.error(
+                          errorMessage(error, "Failed to remove the agent."),
+                        );
+                      }
+                    }}
+                    onToggle={async (enabled) => {
+                      try {
+                        await setEnabled(
+                          agent,
+                          enabled,
+                          personaByName.get(agent.slug),
+                        );
+                      } catch (error) {
+                        toast.error(
+                          errorMessage(error, "Failed to update the agent."),
+                        );
+                      }
+                    }}
+                  />
+                ))}
+              </ul>
+            )}
+          </div>
+        );
+      })}
+
+      <SpawnerConnectCard />
+    </section>
+  );
+}
+
+/**
+ * Spawners that announced themselves but are not connected yet.
+ *
+ * Deliberately worded as a claim rather than an endorsement: anyone can publish
+ * a kind:10180, so this list is a phone book. Connecting is a user action, and
+ * running an agent still needs a signed attestation.
+ */
+function DiscoveredSpawners({ spawners }: { spawners: SpawnerAnnouncement[] }) {
+  return (
+    <div className="space-y-2 rounded-md border border-border p-3">
+      <p className="text-sm font-medium">Spawners on this relay</p>
+      <p className="text-2xs text-muted-foreground">
+        Anyone can advertise here. Connecting does not grant access — you still
+        approve each agent's key.
+      </p>
+      <ul className="space-y-2">
+        {spawners.map((spawner) => (
+          <li className="flex items-center gap-3" key={spawner.pubkey}>
+            <div className="min-w-0 flex-1">
+              <p className="truncate text-sm">
+                {spawner.name}{" "}
+                <span className="text-2xs text-muted-foreground">
+                  {shortenPubkey(spawner.pubkey)}
+                </span>
+              </p>
+              <p className="text-2xs text-muted-foreground">
+                {spawner.agentsRunning}/{spawner.maxAgents} agents
+                {runtimeLabel(spawner.runtime)
+                  ? ` · ${runtimeLabel(spawner.runtime)}`
+                  : ""}
+                {spawner.description ? ` · ${spawner.description}` : ""}
+              </p>
+            </div>
+            <Button
+              onClick={() => {
+                if (!addSpawner(spawner.pubkey)) {
+                  toast.error("That spawner advertised an invalid public key.");
+                }
+              }}
+              size="sm"
+              type="button"
+              variant="outline"
+            >
+              Connect
+            </Button>
+          </li>
+        ))}
+      </ul>
+    </div>
+  );
+}
+
+function ServerAgentRow({
+  agent,
+  isPending,
+  onToggle,
+  onRemove,
+}: {
+  agent: ServerAgent;
+  isPending: boolean;
+  onToggle: (enabled: boolean) => void;
+  onRemove: () => void;
+}) {
+  const { label, variant } = phaseLabel(agent.status.phase);
+  const isStopped = agent.status.phase === "stopped";
+
+  return (
+    <li className="flex items-start gap-3 rounded-md border border-border p-3">
+      <div className="min-w-0 flex-1 space-y-1">
+        <div className="flex items-center gap-2">
+          <span className="truncate text-sm font-medium">{agent.slug}</span>
+          <Badge variant={variant}>{label}</Badge>
+        </div>
+        {agent.status.agentPubkey ? (
+          <p className="text-2xs text-muted-foreground">
+            {shortenPubkey(agent.status.agentPubkey)}
+          </p>
+        ) : null}
+        {agent.status.error ? (
+          <p className="flex items-start gap-1 text-xs text-destructive">
+            <CircleAlert aria-hidden className="mt-0.5 size-3 shrink-0" />
+            <span className="min-w-0 break-words">{agent.status.error}</span>
+          </p>
+        ) : null}
+        {agent.status.restartCount > 0 ? (
+          <p className="text-2xs text-muted-foreground">
+            {agent.status.restartCount} failed start
+            {agent.status.restartCount === 1 ? "" : "s"}
+          </p>
+        ) : null}
+      </div>
+      <div className="flex shrink-0 items-center gap-1">
+        <Button
+          aria-label={isStopped ? "Start agent" : "Stop agent"}
+          disabled={isPending}
+          onClick={() => onToggle(isStopped)}
+          size="icon"
+          type="button"
+          variant="ghost"
+        >
+          {isStopped ? (
+            <Play className="size-4" />
+          ) : (
+            <Square className="size-4" />
+          )}
+        </Button>
+        <Button
+          aria-label="Remove agent"
+          disabled={isPending}
+          onClick={onRemove}
+          size="icon"
+          type="button"
+          variant="ghost"
+        >
+          <Trash2 className="size-4" />
+        </Button>
+      </div>
+    </li>
+  );
+}
+
+/**
+ * Picks a persona and a spawner to run it on.
+ *
+ * Grouped by spawner rather than asking twice: with several hosts connected,
+ * "which agent, and where" is one decision. A persona already deployed to a
+ * given host is omitted from that host's group, so a re-deploy cannot silently
+ * overwrite a running agent's spec — but it stays available on other hosts,
+ * since running the same persona in two places is legitimate.
+ */
+function DeployMenu({
+  personas,
+  spawners,
+  directory,
+  disabled,
+  hasServerAgent,
+  onDeploy,
+}: {
+  personas: AgentPersona[];
+  spawners: readonly string[];
+  directory: ReadonlyMap<string, SpawnerAnnouncement>;
+  disabled: boolean;
+  hasServerAgent: (persona: AgentPersona, spawner: string) => boolean;
+  onDeploy: (persona: AgentPersona, spawner: string) => void;
+}) {
+  const groups = spawners.map((spawner) => ({
+    spawner,
+    personas: personas.filter((p) => !hasServerAgent(p, spawner)),
+  }));
+  const hasAnything = groups.some((g) => g.personas.length > 0);
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button
+          disabled={disabled || !hasAnything}
+          size="sm"
+          type="button"
+          variant="outline"
+        >
+          Deploy agent
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end">
+        {groups.map((group, index) => (
+          <React.Fragment key={group.spawner}>
+            {index > 0 ? <DropdownMenuSeparator /> : null}
+            <DropdownMenuLabel>
+              {spawnerLabel(group.spawner, directory)}
+            </DropdownMenuLabel>
+            {group.personas.length === 0 ? (
+              <DropdownMenuItem disabled>All agents deployed</DropdownMenuItem>
+            ) : (
+              group.personas.map((persona) => (
+                <DropdownMenuItem
+                  key={persona.id}
+                  onSelect={() => onDeploy(persona, group.spawner)}
+                >
+                  {persona.displayName}
+                </DropdownMenuItem>
+              ))
+            )}
+          </React.Fragment>
+        ))}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  );
+}
+
+/**
+ * Marks a spawner as the default location for new agents.
+ *
+ * Location is stored once and inherited, rather than copied onto each persona:
+ * the built-in Fizz/Honey/Bumble carry no location of their own, so flipping
+ * this moves every agent that has not been given an explicit one.
+ */
+function DefaultLocationToggle({ spawner }: { spawner: string }) {
+  const current = useDefaultAgentLocation();
+  const isDefault = sameLocation(current, {
+    kind: "spawner",
+    spawnerPubkey: spawner,
+  });
+
+  if (isDefault) {
+    return (
+      <span className="flex items-center gap-1 text-xs text-muted-foreground">
+        <Check aria-hidden className="size-3" />
+        Default for new agents
+      </span>
+    );
+  }
+  return (
+    <button
+      className="text-xs text-muted-foreground underline underline-offset-2"
+      onClick={() => {
+        if (
+          !setDefaultAgentLocation({ kind: "spawner", spawnerPubkey: spawner })
+        ) {
+          toast.error("That spawner key is not valid.");
+          return;
+        }
+        toast.success("New agents will run here by default.");
+      }}
+      type="button"
+    >
+      Make default
+    </button>
+  );
+}
+
+/** Connect to a spawner by pubkey, for one that has not announced itself. */
+function SpawnerConnectCard() {
+  const [value, setValue] = React.useState("");
+
+  return (
+    <div className="flex gap-2">
+      <Input
+        onChange={(event) => setValue(event.target.value)}
+        placeholder="Spawner public key (64 hex characters)"
+        value={value}
+      />
+      <Button
+        disabled={value.trim().length === 0}
+        onClick={() => {
+          if (!addSpawner(value.trim())) {
+            toast.error("That is not a valid 64-character hex public key.");
+            return;
+          }
+          setValue("");
+        }}
+        type="button"
+        variant="outline"
+      >
+        Connect
+      </Button>
+    </div>
+  );
+}
+
+function errorMessage(error: unknown, fallback: string): string {
+  return error instanceof Error ? error.message : fallback;
+}
diff --git a/desktop/src/features/agents/ui/SpawnerAttestationDialog.tsx b/desktop/src/features/agents/ui/SpawnerAttestationDialog.tsx
new file mode 100644
index 0000000000..78c6181e6e
--- /dev/null
+++ b/desktop/src/features/agents/ui/SpawnerAttestationDialog.tsx
@@ -0,0 +1,217 @@
+import React from "react";
+import { toast } from "sonner";
+
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/shared/ui/alert-dialog";
+import { Button } from "@/shared/ui/button";
+import { Checkbox } from "@/shared/ui/checkbox";
+import { useManagedAgentsQuery } from "@/features/agents/hooks";
+import { isRelocationOfLocalAgent } from "../agentRelocation";
+import {
+  approveAttestation,
+  declineAttestation,
+  usePendingAttestations,
+  type PendingAttestation,
+} from "../spawnerAttestationStore";
+
+/** Shorten a 64-char pubkey for display without losing recognizability. */
+export function shortenPubkey(pubkey: string): string {
+  if (pubkey.length <= 20) return pubkey;
+  return `${pubkey.slice(0, 10)}…${pubkey.slice(-6)}`;
+}
+
+/**
+ * Consent copy for an attestation request.
+ *
+ * Pure so the wording stays unit-testable. It has to say plainly what a
+ * signature does — this is not "allow an app to run", it is "let this key act
+ * in your community under your membership" — because a user who misreads it
+ * grants relay access to a server they may not control.
+ */
+export function attestationDescription(
+  item: PendingAttestation | null,
+  options?: { isRelocation?: boolean },
+): string {
+  if (!item) return "A server wants to run an agent for you.";
+  if (options?.isRelocation) {
+    // A relocation is a different decision from authorizing a stranger: the
+    // key already belongs to the user, and what they are consenting to is
+    // handing it to the server and giving up the local copy. Reusing the "a
+    // new agent key was created" copy here would be a lie in both halves.
+    return (
+      `The spawner ${shortenPubkey(item.spawnerPubkey)} is asking to take over ` +
+      `"${item.specSlug}", an agent you already run on this Mac.\n\n` +
+      `Approving moves ${shortenPubkey(item.agentPubkey)} to that server. It keeps ` +
+      `the same identity — its channels, its profile, and its memory all follow ` +
+      `it — and it will stop running on this Mac. ` +
+      `Only approve this if you run this server.`
+    );
+  }
+  return (
+    `The spawner ${shortenPubkey(item.spawnerPubkey)} created a new agent key ` +
+    `for "${item.specSlug}" and is asking you to authorize it.\n\n` +
+    `Approving signs an owner attestation for ${shortenPubkey(item.agentPubkey)}, ` +
+    `which lets that key join and read your channels as an agent you own. ` +
+    `Only approve this if you run this server.`
+  );
+}
+
+/** Dialog title, which also has to change for a move. */
+export function attestationTitle(options?: { isRelocation?: boolean }): string {
+  return options?.isRelocation
+    ? "Move this agent to a server?"
+    : "Authorize a server agent?";
+}
+
+type SpawnerAttestationDialogProps = {
+  /** Injectable for tests; defaults to the live queue. */
+  pending?: readonly PendingAttestation[];
+};
+
+/**
+ * Prompts the owner to approve or decline a server-hosted agent's key.
+ *
+ * Rendered app-wide so a request that arrives while the user is anywhere in the
+ * app still surfaces. It shows one request at a time — the head of the queue —
+ * because each is a distinct consent decision and stacking them invites
+ * click-through.
+ */
+export function SpawnerAttestationDialog({
+  pending: injectedPending,
+}: SpawnerAttestationDialogProps = {}) {
+  const livePending = usePendingAttestations();
+  const pending = injectedPending ?? livePending;
+  const current = pending[0] ?? null;
+
+  // A request naming an agent this device already manages is a *move*, not a
+  // new key. Read from the managed-agent list rather than trusting anything in
+  // the frame: only local state can say whether we already own this identity.
+  const managedAgentsQuery = useManagedAgentsQuery();
+  const isRelocation = isRelocationOfLocalAgent(
+    current?.agentPubkey,
+    React.useMemo(
+      () => (managedAgentsQuery.data ?? []).map((agent) => agent.pubkey),
+      [managedAgentsQuery.data],
+    ),
+  );
+
+  const [isSubmitting, setIsSubmitting] = React.useState(false);
+
+  // Each request is its own consent decision, so "trust this spawner" must not
+  // carry over from a previous prompt the user happened to tick. Storing the
+  // nonce alongside the value resets it during render (React's "adjusting state
+  // when a prop changes" pattern) rather than in an effect, so the checkbox is
+  // never briefly shown ticked for the next request.
+  const [rememberState, setRememberState] = React.useState<{
+    nonce: string | null;
+    value: boolean;
+  }>({ nonce: null, value: false });
+  const remember =
+    rememberState.nonce === (current?.nonce ?? null)
+      ? rememberState.value
+      : false;
+  const setRemember = (value: boolean) => {
+    setRememberState({ nonce: current?.nonce ?? null, value });
+  };
+
+  const handleApprove = React.useCallback(async () => {
+    if (!current || isSubmitting) return;
+    setIsSubmitting(true);
+    try {
+      await approveAttestation(current, { remember });
+      toast.success(
+        isRelocation
+          ? `Moved "${current.specSlug}" to the server.`
+          : `Authorized the server agent "${current.specSlug}".`,
+      );
+    } catch (error) {
+      toast.error(
+        error instanceof Error
+          ? error.message
+          : "Failed to authorize the server agent.",
+      );
+    } finally {
+      setIsSubmitting(false);
+    }
+  }, [current, isRelocation, isSubmitting, remember]);
+
+  const handleDecline = React.useCallback(async () => {
+    if (!current || isSubmitting) return;
+    setIsSubmitting(true);
+    try {
+      await declineAttestation(current);
+    } catch (error) {
+      toast.error(
+        error instanceof Error
+          ? error.message
+          : "Failed to decline the server agent.",
+      );
+    } finally {
+      setIsSubmitting(false);
+    }
+  }, [current, isSubmitting]);
+
+  return (
+    <AlertDialog
+      onOpenChange={(open) => {
+        // Dismissing without a decision would leave the agent stuck at
+        // pending_attestation until the spawner times out, so closing declines.
+        if (!open) void handleDecline();
+      }}
+      open={current !== null}
+    >
+      <AlertDialogContent>
+        <AlertDialogHeader>
+          <AlertDialogTitle>
+            {attestationTitle({ isRelocation })}
+          </AlertDialogTitle>
+          <AlertDialogDescription className="whitespace-pre-line">
+            {attestationDescription(current, { isRelocation })}
+          </AlertDialogDescription>
+        </AlertDialogHeader>
+        <label
+          className="flex w-fit cursor-pointer items-center gap-2 text-sm text-muted-foreground"
+          htmlFor="remember-spawner"
+        >
+          <Checkbox
+            checked={remember}
+            data-testid="remember-spawner"
+            disabled={isSubmitting}
+            id="remember-spawner"
+            onCheckedChange={(checked) => setRemember(checked === true)}
+          />
+          Trust this spawner for future agents
+        </label>
+        <AlertDialogFooter>
+          <AlertDialogCancel asChild>
+            <Button disabled={isSubmitting} type="button" variant="outline">
+              Decline
+            </Button>
+          </AlertDialogCancel>
+          <AlertDialogAction asChild>
+            <Button
+              disabled={isSubmitting}
+              onClick={(event) => {
+                // Keep the dialog mounted until the signature is published, so
+                // a failure surfaces on this prompt rather than after it closes.
+                event.preventDefault();
+                void handleApprove();
+              }}
+              type="button"
+            >
+              Approve
+            </Button>
+          </AlertDialogAction>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
+  );
+}
diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
index 9bbe3feef7..bac1b9718d 100644
--- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
+++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
@@ -303,6 +303,7 @@ function AgentPersonaCard({
             errorLabel={friendlyError}
             errorTestId={`agent-runtime-error-${agent.pubkey}`}
             isActive={isActive}
+            isRelocated={agent.relocatedToSpawner !== null}
             isStarting={startingAgentPubkey === agent.pubkey}
             label={title}
             startTestId={`agent-runtime-start-${agent.pubkey}`}
@@ -389,6 +390,7 @@ function StandaloneAgentCard({
           errorLabel={friendlyError}
           errorTestId={`agent-runtime-error-${agent.pubkey}`}
           isActive={isActive}
+          isRelocated={agent.relocatedToSpawner !== null}
           isStarting={startingAgentPubkey === agent.pubkey}
           label={title}
           startTestId={`agent-runtime-start-${agent.pubkey}`}
diff --git a/desktop/src/features/agents/ui/agentLocationOptions.test.mjs b/desktop/src/features/agents/ui/agentLocationOptions.test.mjs
new file mode 100644
index 0000000000..95bddeec07
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentLocationOptions.test.mjs
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  agentLocationOptions,
+  agentLocationValue,
+  LOCAL_LOCATION_VALUE,
+  parseAgentLocationValue,
+} from "./agentLocationOptions.ts";
+
+const SPAWNER_A = "aa".repeat(32);
+const SPAWNER_B = "bb".repeat(32);
+
+const describe = (pubkey) =>
+  pubkey === SPAWNER_A
+    ? { label: "GPU box", hint: "Claude Code" }
+    : { label: "VPS" };
+
+test("offersNoChoiceWhenNoSpawnerIsConnected", () => {
+  // The caller hides the control entirely rather than rendering a dropdown
+  // whose only entry is the thing that already happens.
+  assert.deepEqual(agentLocationOptions([], describe), []);
+});
+
+test("listsThisMacFirstThenEachSpawner", () => {
+  assert.deepEqual(agentLocationOptions([SPAWNER_A, SPAWNER_B], describe), [
+    { value: LOCAL_LOCATION_VALUE, label: "This Mac" },
+    { value: SPAWNER_A, label: "GPU box", hint: "Claude Code" },
+    { value: SPAWNER_B, label: "VPS" },
+  ]);
+});
+
+test("roundTripsALocationThroughTheSelectValue", () => {
+  const location = { kind: "spawner", spawnerPubkey: SPAWNER_A };
+  assert.equal(agentLocationValue(location), SPAWNER_A);
+  assert.deepEqual(
+    parseAgentLocationValue(agentLocationValue(location), [SPAWNER_A]),
+    location,
+  );
+  assert.equal(agentLocationValue({ kind: "local" }), LOCAL_LOCATION_VALUE);
+  assert.deepEqual(parseAgentLocationValue(LOCAL_LOCATION_VALUE, [SPAWNER_A]), {
+    kind: "local",
+  });
+});
+
+test("fallsBackToLocalForASpawnerThatIsNoLongerConnected", () => {
+  // A spawner can disconnect while the dialog is open; deploying to it would
+  // fail with no obvious cause.
+  assert.deepEqual(parseAgentLocationValue(SPAWNER_A, [SPAWNER_B]), {
+    kind: "local",
+  });
+  assert.deepEqual(parseAgentLocationValue("", [SPAWNER_A]), { kind: "local" });
+});
+
+test("acceptsAnUppercasePubkeyValue", () => {
+  assert.deepEqual(
+    parseAgentLocationValue(SPAWNER_A.toUpperCase(), [SPAWNER_A]),
+    { kind: "spawner", spawnerPubkey: SPAWNER_A },
+  );
+});
diff --git a/desktop/src/features/agents/ui/agentLocationOptions.ts b/desktop/src/features/agents/ui/agentLocationOptions.ts
new file mode 100644
index 0000000000..c1e8912148
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentLocationOptions.ts
@@ -0,0 +1,71 @@
+import { LOCAL, type AgentLocation } from "../agentLocation";
+
+/** `<select>` value that means "run this agent on this computer". */
+export const LOCAL_LOCATION_VALUE = "local";
+
+/** One entry of the create dialog's "Runs on" control. */
+export type AgentLocationOption = {
+  /** `LOCAL_LOCATION_VALUE`, or the spawner's pubkey. */
+  value: string;
+  label: string;
+  /** Secondary detail — the runtime a spawner advertises, when it announced one. */
+  hint?: string;
+};
+
+/**
+ * Resolves display text for a spawner pubkey.
+ *
+ * Injected rather than imported so this module stays free of React and of the
+ * spawner directory store: the labels come from an announcement that may not
+ * have arrived, and the option list is otherwise pure.
+ */
+export type SpawnerDescriptor = (pubkey: string) => {
+  label: string;
+  hint?: string;
+};
+
+/**
+ * The locations a new agent can be created in.
+ *
+ * Empty when no spawner is connected — there is exactly one possible answer in
+ * that case, so the caller hides the control rather than rendering a one-item
+ * dropdown.
+ */
+export function agentLocationOptions(
+  spawners: readonly string[],
+  describe: SpawnerDescriptor,
+): AgentLocationOption[] {
+  if (spawners.length === 0) return [];
+  return [
+    { value: LOCAL_LOCATION_VALUE, label: "This Mac" },
+    ...spawners.map((pubkey) => {
+      const { label, hint } = describe(pubkey);
+      return { value: pubkey, label, ...(hint ? { hint } : {}) };
+    }),
+  ];
+}
+
+/** The `<select>` value for a location. */
+export function agentLocationValue(location: AgentLocation): string {
+  return location.kind === "local"
+    ? LOCAL_LOCATION_VALUE
+    : location.spawnerPubkey;
+}
+
+/**
+ * The location a `<select>` value refers to.
+ *
+ * A value that is not a currently connected spawner resolves to local: a
+ * spawner can disconnect while the dialog is open, and deploying to one this
+ * device no longer manages would fail with no obvious cause.
+ */
+export function parseAgentLocationValue(
+  value: string,
+  spawners: readonly string[],
+): AgentLocation {
+  const pubkey = value.trim().toLowerCase();
+  if (pubkey === LOCAL_LOCATION_VALUE) return LOCAL;
+  return spawners.includes(pubkey)
+    ? { kind: "spawner", spawnerPubkey: pubkey }
+    : LOCAL;
+}
diff --git a/desktop/src/features/agents/ui/spawnerAttestationCopy.test.mjs b/desktop/src/features/agents/ui/spawnerAttestationCopy.test.mjs
new file mode 100644
index 0000000000..6b894c0d96
--- /dev/null
+++ b/desktop/src/features/agents/ui/spawnerAttestationCopy.test.mjs
@@ -0,0 +1,92 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  attestationDescription,
+  attestationTitle,
+  shortenPubkey,
+} from "./SpawnerAttestationDialog.tsx";
+
+const SPAWNER =
+  "1111111111111111111111111111111111111111111111111111111111111111";
+const AGENT =
+  "2222222222222222222222222222222222222222222222222222222222222222";
+
+const request = {
+  spawnerPubkey: SPAWNER,
+  specSlug: "fizz-prod",
+  agentPubkey: AGENT,
+  conditions: "",
+  nonce: "ab".repeat(32),
+  encryptedContent: "ciphertext",
+  eventId: "e".repeat(64),
+};
+
+test("shortensLongPubkeysButLeavesShortStringsAlone", () => {
+  const short = shortenPubkey(SPAWNER);
+  assert.ok(short.length < SPAWNER.length);
+  assert.ok(short.startsWith("1111111111"));
+  assert.equal(shortenPubkey("short"), "short");
+});
+
+test("namesBothTheSpawnerAndTheAgentBeingAuthorized", () => {
+  // The user has to be able to tell *which* key they are vouching for. Naming
+  // only the spawner would let one request authorize any agent.
+  const copy = attestationDescription(request);
+
+  assert.ok(copy.includes(shortenPubkey(SPAWNER)));
+  assert.ok(copy.includes(shortenPubkey(AGENT)));
+  assert.ok(copy.includes("fizz-prod"));
+});
+
+test("statesThatApprovingGrantsChannelAccess", () => {
+  // Signing an attestation admits the agent under the user's own relay
+  // membership. Consent copy that omits this is not informed consent.
+  const copy = attestationDescription(request);
+
+  assert.ok(/read your channels/i.test(copy));
+  assert.ok(/only approve this if you run this server/i.test(copy));
+});
+
+test("hasFallbackCopyWhenNoRequestIsPending", () => {
+  // Rendered during the dialog's close animation, after the queue has drained.
+  assert.equal(
+    attestationDescription(null),
+    "A server wants to run an agent for you.",
+  );
+});
+
+test("saysTheAgentIsMovingAndWillStopLocallyWhenRelocating", () => {
+  // The user already owns this key. Telling them a "new agent key" was created
+  // would hide the only consequence that matters: the agent leaves this Mac.
+  const copy = attestationDescription(request, { isRelocation: true });
+
+  assert.ok(/already run on this Mac/i.test(copy));
+  assert.ok(/stop running on this Mac/i.test(copy));
+  assert.ok(!/created a new agent key/i.test(copy));
+  assert.ok(copy.includes(shortenPubkey(AGENT)));
+});
+
+test("saysTheIdentityIsPreservedAcrossTheMove", () => {
+  // Losing the key would orphan the agent's memory, so "same identity" is the
+  // reassurance that makes approving reasonable.
+  const copy = attestationDescription(request, { isRelocation: true });
+
+  assert.ok(/same identity/i.test(copy));
+  assert.ok(/memory/i.test(copy));
+});
+
+test("keepsTheNewKeyCopyWhenTheAgentIsNotOneWeAlreadyRun", () => {
+  const copy = attestationDescription(request, { isRelocation: false });
+
+  assert.ok(/created a new agent key/i.test(copy));
+  assert.ok(!/stop running on this Mac/i.test(copy));
+});
+
+test("titleDistinguishesAMoveFromANewAuthorization", () => {
+  assert.equal(
+    attestationTitle({ isRelocation: true }),
+    "Move this agent to a server?",
+  );
+  assert.equal(attestationTitle(), "Authorize a server agent?");
+});
diff --git a/desktop/src/features/agents/ui/useDeployPersonaToSpawner.ts b/desktop/src/features/agents/ui/useDeployPersonaToSpawner.ts
new file mode 100644
index 0000000000..8ce08dc14a
--- /dev/null
+++ b/desktop/src/features/agents/ui/useDeployPersonaToSpawner.ts
@@ -0,0 +1,39 @@
+import * as React from "react";
+import { toast } from "sonner";
+
+import type { AgentPersona } from "@/shared/api/types";
+import { useSpawnerDirectory } from "../spawnerDirectoryStore";
+import { useServerAgents } from "../useServerAgents";
+import { spawnerLabel } from "./ServerAgentsSection";
+
+/**
+ * Publishes the spec that makes a persona run on a spawner, with feedback.
+ *
+ * Shared by every create surface so "Runs on: <server>" means the same thing
+ * wherever it is chosen. No local instance is created and no binary is named:
+ * the host decides what executes there.
+ */
+export function useDeployPersonaToSpawner() {
+  const { create } = useServerAgents();
+  const directory = useSpawnerDirectory();
+
+  return React.useCallback(
+    async (persona: AgentPersona, spawnerPubkey: string): Promise<boolean> => {
+      try {
+        await create(persona, spawnerPubkey);
+        toast.success(
+          `Deploying ${persona.displayName} to ${spawnerLabel(spawnerPubkey, directory)}. Approve the key when prompted.`,
+        );
+        return true;
+      } catch (error) {
+        toast.error(
+          error instanceof Error
+            ? error.message
+            : `${persona.displayName} was created, but it could not be deployed to the server.`,
+        );
+        return false;
+      }
+    },
+    [create, directory],
+  );
+}
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts
index 2c7668969a..19e980201c 100644
--- a/desktop/src/features/agents/ui/usePersonaActions.ts
+++ b/desktop/src/features/agents/ui/usePersonaActions.ts
@@ -63,6 +63,8 @@ import {
   buildInstanceInputForDefinition,
   type BackendIntent,
 } from "../lib/instanceInputForDefinition";
+import { LOCAL, type AgentLocation } from "../agentLocation";
+import { useDeployPersonaToSpawner } from "./useDeployPersonaToSpawner";
 
 type PersonaFeedbackSurface = "catalog" | "library";
 
@@ -125,6 +127,7 @@ export function usePersonaActions() {
   const [personaFeedbackSurface, setPersonaFeedbackSurface] =
     React.useState<PersonaFeedbackSurface>("library");
   const createdAgentAttachment = useCreatedAgentChannelAttachment();
+  const deployToSpawner = useDeployPersonaToSpawner();
   const [isPersonaSubmitPending, setIsPersonaSubmitPending] =
     React.useState(false);
 
@@ -176,6 +179,7 @@ export function usePersonaActions() {
     input: CreatePersonaInput | UpdatePersonaInput,
     intent?: AgentCreateIntent,
     backendIntent?: BackendIntent | null,
+    location?: AgentLocation,
     targetChannel?: Pick<Channel, "id" | "name"> | null,
     options?: { publishCatalogUpdates?: boolean },
   ): Promise<boolean> {
@@ -237,6 +241,17 @@ export function usePersonaActions() {
           setPersonaDialogState(null);
           return true;
         }
+
+        // Server-hosted: the spawner owns the process, so no local instance is
+        // created and nothing is spawned here. The definition is still created
+        // — the spawner resolves the prompt from it.
+        const runLocation = location ?? LOCAL;
+        if (runLocation.kind === "spawner") {
+          await deployToSpawner(persona, runLocation.spawnerPubkey);
+          setPersonaDialogState(null);
+          return true;
+        }
+
         const agentInput = await buildInstanceInputForDefinition(
           persona,
           runtime,
diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts
index f4cdb895ef..0d345990ac 100644
--- a/desktop/src/features/agents/useAgentManagement.ts
+++ b/desktop/src/features/agents/useAgentManagement.ts
@@ -23,6 +23,8 @@ import {
   type BackendIntent,
 } from "./lib/instanceInputForDefinition";
 import { useCreatedAgentChannelAttachment } from "./useCreatedAgentChannelAttachment";
+import { LOCAL, type AgentLocation } from "./agentLocation";
+import { useDeployPersonaToSpawner } from "./ui/useDeployPersonaToSpawner";
 import { classifyAgentManagementOrigin } from "./agentManagementBuffer";
 import { useChannelsQuery } from "@/features/channels/hooks";
 import { resolveManagedAgentAvatarUrl } from "./ui/managedAgentAvatar";
@@ -71,6 +73,7 @@ export function useAgentManagement() {
   );
   const [error, setError] = React.useState<string | null>(null);
   const createdAgentAttachment = useCreatedAgentChannelAttachment();
+  const deployToSpawner = useDeployPersonaToSpawner();
   const seenRequestIds = React.useRef(new Set<string>());
   const pendingRequestId = React.useRef<string | null>(null);
   const sourceAgentPubkey = React.useRef<string | null>(null);
@@ -179,6 +182,7 @@ export function useAgentManagement() {
     input: CreatePersonaInput | UpdatePersonaInput,
     intent: AgentCreateIntent,
     backendIntent: BackendIntent | null,
+    location?: AgentLocation,
   ): Promise<boolean> {
     if (request?.action !== "create" || "id" in input) {
       return false;
@@ -204,7 +208,12 @@ export function useAgentManagement() {
         avatarUrl,
       });
 
-      if (intent === "definition_start") {
+      const runLocation = location ?? LOCAL;
+      if (runLocation.kind === "spawner") {
+        // The spawner owns the process and mints the agent's key, so there is
+        // no local instance to create and no pubkey to add to the channel yet.
+        await deployToSpawner(persona, runLocation.spawnerPubkey);
+      } else if (intent === "definition_start") {
         const created = await createAgentMutation.mutateAsync(
           await buildInstanceInputForDefinition(
             persona,
diff --git a/desktop/src/features/agents/useServerAgents.ts b/desktop/src/features/agents/useServerAgents.ts
new file mode 100644
index 0000000000..338e07013a
--- /dev/null
+++ b/desktop/src/features/agents/useServerAgents.ts
@@ -0,0 +1,216 @@
+import React from "react";
+
+import {
+  deleteSpawnerAgentSpec,
+  publishSpawnerAgentSpec,
+  type SpawnerAgentStatus,
+} from "@/shared/api/spawnerRelay";
+import type { AgentPersona } from "@/shared/api/types";
+import { buildRelocationPlan, type RelocatableAgent } from "./agentRelocation";
+import { personaDTag, slugFromName, useSpawners } from "./spawnerPreference";
+import { spawnerStatusKey, useSpawnerStatuses } from "./spawnerStatusStore";
+
+/**
+ * Persona reference for a spec.
+ *
+ * Never the prompt itself: a kind:30178 spec is world-readable, so inlining a
+ * system prompt would publish it to the whole community. The prompt travels
+ * over the encrypted kind:24201 handshake at approval time instead
+ * (`setSpawnerPromptResolver`).
+ *
+ * The reference is the persona's *relay* address, which is not its local id —
+ * `builtin:fizz` is published as `builtin-fizz`, because the relay rejects a
+ * colon in a `d` tag. A built-in that was never published simply resolves to
+ * nothing on the relay, which is fine: the handshake supplies the prompt.
+ */
+function promptFieldsFor(persona: AgentPersona) {
+  return { personaId: personaDTag(persona.id) };
+}
+
+/** A server-hosted agent as the Agents screen renders it. */
+export type ServerAgent = {
+  slug: string;
+  spawnerPubkey: string;
+  status: SpawnerAgentStatus;
+};
+
+/**
+ * Server-hosted agents and the actions that manage them.
+ *
+ * Reads the list from kind:30179 status rather than from the specs this device
+ * published: a spec is desired state that may have been created on another
+ * device, and status is what the spawner is actually doing. Anything the
+ * configured spawner reports is shown, so a phone sees the agents a laptop
+ * created.
+ */
+export function useServerAgents() {
+  const spawners = useSpawners();
+  const statuses = useSpawnerStatuses();
+  const [isPending, setIsPending] = React.useState(false);
+
+  const agents = React.useMemo<ServerAgent[]>(() => {
+    if (spawners.length === 0) return [];
+    return [...statuses.entries()]
+      .flatMap(([key, status]) => {
+        // Status from a spawner this device is not connected to belongs to
+        // somebody else's deployment, or to an impostor publishing at their own
+        // NIP-33 address. Either way it is not this user's agent.
+        const spawnerPubkey = spawners.find((s) => key.startsWith(`${s}/`));
+        if (!spawnerPubkey) return [];
+        return [
+          {
+            slug: key.slice(spawnerPubkey.length + 1),
+            spawnerPubkey,
+            status,
+          },
+        ];
+      })
+      .sort(
+        (a, b) =>
+          a.spawnerPubkey.localeCompare(b.spawnerPubkey) ||
+          a.slug.localeCompare(b.slug),
+      );
+  }, [spawners, statuses]);
+
+  /**
+   * Create a server agent from a persona.
+   *
+   * The spec carries `personaId` rather than the prompt itself, matching how
+   * kind:30177 slims against kind:30175 — the spawner resolves the prompt from
+   * the persona, so it lives in exactly one place and editing the persona
+   * updates the server agent too. Built-in personas are the exception: they
+   * ship with the app and have no kind:30175 event, so the spawner could never
+   * read them — their prompt goes inline.
+   */
+  const create = React.useCallback(
+    async (
+      persona: AgentPersona,
+      spawnerPubkey: string,
+      options?: { slug?: string },
+    ) => {
+      if (!spawnerPubkey) throw new Error("No spawner selected.");
+      const slug = options?.slug ?? slugFromName(persona.displayName);
+      if (!slug) {
+        throw new Error(
+          "Could not derive a name for this agent. Rename it using letters or digits.",
+        );
+      }
+      setIsPending(true);
+      try {
+        await publishSpawnerAgentSpec({
+          slug,
+          spawnerPubkey,
+          spec: {
+            name: persona.displayName,
+            ...promptFieldsFor(persona),
+            parallelism: 1,
+            respondTo: "anyone",
+            enabled: true,
+          },
+        });
+        return slug;
+      } finally {
+        setIsPending(false);
+      }
+    },
+    [],
+  );
+
+  /**
+   * Move an agent that already exists on this device onto a spawner.
+   *
+   * Distinct from `create`, which mints a new key: this publishes the existing
+   * agent's *public* key on the spec, which is what tells the spawner to ask
+   * for that identity's secret over the encrypted handshake rather than
+   * generate a stranger. The local process is stopped later, by the attestation
+   * store, once the handshake response has actually gone out.
+   */
+  const relocate = React.useCallback(
+    async (agent: RelocatableAgent, spawnerPubkey: string) => {
+      if (!spawnerPubkey) throw new Error("No spawner selected.");
+      const { slug, spec } = buildRelocationPlan(agent);
+      setIsPending(true);
+      try {
+        await publishSpawnerAgentSpec({ slug, spawnerPubkey, spec });
+        return slug;
+      } finally {
+        setIsPending(false);
+      }
+    },
+    [],
+  );
+
+  /**
+   * Stop or resume an agent without touching its identity.
+   *
+   * Republishes the spec with `enabled` flipped rather than deleting it, so the
+   * agent keeps its pubkey, its attestation, and its workspace volume — a
+   * resumed agent is the same agent, not a stranger wearing its name.
+   */
+  const setEnabled = React.useCallback(
+    async (agent: ServerAgent, enabled: boolean, persona?: AgentPersona) => {
+      setIsPending(true);
+      try {
+        await publishSpawnerAgentSpec({
+          slug: agent.slug,
+          spawnerPubkey: agent.spawnerPubkey,
+          spec: {
+            name: persona?.displayName ?? agent.slug,
+            // A definition-less spec needs an inline prompt to validate; this
+            // placeholder only applies when the persona is gone, which is
+            // itself a broken state the spawner will report.
+            ...(persona
+              ? promptFieldsFor(persona)
+              : { systemPrompt: "Server-hosted Buzz agent." }),
+            parallelism: 1,
+            respondTo: "anyone",
+            enabled,
+          },
+        });
+      } finally {
+        setIsPending(false);
+      }
+    },
+    [],
+  );
+
+  /** Permanently remove an agent: its container, volume, and key. */
+  const remove = React.useCallback(async (agent: ServerAgent) => {
+    setIsPending(true);
+    try {
+      await deleteSpawnerAgentSpec({
+        slug: agent.slug,
+        spawnerPubkey: agent.spawnerPubkey,
+      });
+    } finally {
+      setIsPending(false);
+    }
+  }, []);
+
+  /**
+   * Whether a persona is already deployed to a given spawner.
+   *
+   * Scoped per spawner, not globally: deploying the same persona to two
+   * different hosts is a legitimate thing to want, and only a collision on the
+   * *same* spawner would overwrite a running agent's spec.
+   */
+  const hasServerAgent = React.useCallback(
+    (persona: AgentPersona, spawnerPubkey: string) => {
+      const slug = slugFromName(persona.displayName);
+      if (!slug) return false;
+      return statuses.has(spawnerStatusKey(spawnerPubkey, slug));
+    },
+    [statuses],
+  );
+
+  return {
+    spawners,
+    agents,
+    isPending,
+    create,
+    relocate,
+    setEnabled,
+    remove,
+    hasServerAgent,
+  };
+}
diff --git a/desktop/src/features/agents/useSpawnerIngestion.ts b/desktop/src/features/agents/useSpawnerIngestion.ts
new file mode 100644
index 0000000000..3efea97dd6
--- /dev/null
+++ b/desktop/src/features/agents/useSpawnerIngestion.ts
@@ -0,0 +1,58 @@
+import React from "react";
+
+import { usePersonasQuery } from "@/features/agents/hooks";
+import type { SpawnerPromptMaterial } from "@/shared/api/tauriSpawner";
+import {
+  ensureSpawnerAttestationSubscription,
+  setSpawnerPromptResolver,
+} from "./spawnerAttestationStore";
+import { slugFromName } from "./spawnerPreference";
+import { ensureSpawnerDirectorySubscription } from "./spawnerDirectoryStore";
+import { ensureSpawnerStatusSubscription } from "./spawnerStatusStore";
+
+/**
+ * Opens the owner-scoped spawner subscriptions: kind:24201 attestation
+ * handshakes, kind:30179 status, and kind:10180 spawner announcements.
+ *
+ * Mounted app-wide, once, next to `useAgentObserverIngestion`. Both
+ * subscriptions are owner-global rather than screen-scoped, and the attestation
+ * one in particular must be live everywhere: kind 24201 is ephemeral, so a
+ * request that arrives while the Agents screen is unmounted is gone for good and
+ * the agent sits at `pending_attestation` until the spawner re-sends.
+ *
+ * Deliberately unguarded by `startupReady` for the same reason — the stores
+ * resolve identity themselves and no-op until one exists.
+ */
+export function useSpawnerIngestion(): void {
+  React.useEffect(() => {
+    void ensureSpawnerAttestationSubscription();
+    void ensureSpawnerStatusSubscription();
+    void ensureSpawnerDirectorySubscription();
+  }, []);
+
+  // Prompt material is sent over the encrypted handshake rather than published
+  // on the world-readable spec, so the answer has to be assembled here, where
+  // the persona library is in scope. Registered as a resolver so the store
+  // stays independent of the persona layer.
+  const personas = usePersonasQuery().data;
+  React.useEffect(() => {
+    if (!personas) {
+      setSpawnerPromptResolver(null);
+      return;
+    }
+    setSpawnerPromptResolver((specSlug): SpawnerPromptMaterial | null => {
+      // Specs are named from the persona's display name, so that mapping is
+      // what identifies which persona an inbound request is about.
+      const persona = personas.find(
+        (candidate) => slugFromName(candidate.displayName) === specSlug,
+      );
+      if (!persona) return null;
+      return {
+        system_prompt: persona.systemPrompt || undefined,
+        model: persona.model ?? undefined,
+        provider: persona.provider ?? undefined,
+      };
+    });
+    return () => setSpawnerPromptResolver(null);
+  }, [personas]);
+}
diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts
index abb49485d2..d983573a41 100644
--- a/desktop/src/features/communities/useCommunityInit.ts
+++ b/desktop/src/features/communities/useCommunityInit.ts
@@ -23,6 +23,9 @@ import {
 } from "@/features/agents/activeAgentTurnsStore";
 import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal";
 import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
+import { resetSpawnerAttestationStore } from "@/features/agents/spawnerAttestationStore";
+import { resetSpawnerDirectoryStore } from "@/features/agents/spawnerDirectoryStore";
+import { resetSpawnerStatusStore } from "@/features/agents/spawnerStatusStore";
 import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
 import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync";
 import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
@@ -52,6 +55,13 @@ function resetCommunityState({
   clearAllDrafts();
   resetAgentObserverStore();
   resetActiveAgentTurnsStore();
+  // Attestation prompts are identity-scoped: showing one after a switch would
+  // ask the user to sign with the wrong key. Status is community-scoped too.
+  resetSpawnerAttestationStore();
+  resetSpawnerStatusStore();
+  // The spawner directory is community-scoped: announcements come from the
+  // relay we just left.
+  resetSpawnerDirectoryStore();
   resetAgentWorkingSignal();
   if (resetAvatarState) {
     resetAvatarProfileSync();
diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts
index 6d4e007cd4..a20fe26cc7 100644
--- a/desktop/src/features/messages/ui/useMentionSendFlow.ts
+++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts
@@ -261,6 +261,14 @@ export function useMentionSendFlow({
           continue;
         }
 
+        // A relocated identity runs on its spawner: the server copy sees the
+        // mention through the relay on its own, and any local start/attach
+        // would be refused by the backend guard. Treat it as ready.
+        if (agent.relocatedToSpawner !== null) {
+          pubkeys.push(pubkey);
+          continue;
+        }
+
         try {
           if (participantPubkeys.has(pubkey)) {
             if (isProviderBackedAgent(agent)) {
diff --git a/desktop/src/shared/api/spawnerRelay.test.mjs b/desktop/src/shared/api/spawnerRelay.test.mjs
new file mode 100644
index 0000000000..3f0c426d25
--- /dev/null
+++ b/desktop/src/shared/api/spawnerRelay.test.mjs
@@ -0,0 +1,167 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  parseSpawnerAnnouncement,
+  parseSpawnerStatus,
+  specSlugFromEvent,
+} from "./spawnerRelay.ts";
+
+const statusEvent = (content, tags = [["d", "fizz-prod"]]) => ({
+  id: "e".repeat(64),
+  pubkey: "a".repeat(64),
+  created_at: 1_700_000_000,
+  kind: 30179,
+  tags,
+  content,
+  sig: "f".repeat(128),
+});
+
+test("parsesARunningStatusFromTheSnakeCaseWire", () => {
+  const status = parseSpawnerStatus(
+    JSON.stringify({
+      phase: "running",
+      agent_pubkey: "b".repeat(64),
+      spec_hash: "abc123",
+      restart_count: 0,
+    }),
+  );
+
+  assert.deepEqual(status, {
+    phase: "running",
+    agentPubkey: "b".repeat(64),
+    specHash: "abc123",
+    error: undefined,
+    restartCount: 0,
+  });
+});
+
+test("parsesAFailedStatusWithItsError", () => {
+  const status = parseSpawnerStatus(
+    JSON.stringify({
+      phase: "failed",
+      error: "image pull failed",
+      restart_count: 3,
+    }),
+  );
+
+  assert.equal(status.phase, "failed");
+  assert.equal(status.error, "image pull failed");
+  assert.equal(status.restartCount, 3);
+});
+
+test("restartCountDefaultsToZeroWhenOmitted", () => {
+  // The Rust projection skips restart_count when it is 0, so a healthy agent's
+  // status has no such field. Defaulting to 0 keeps the UI from rendering
+  // "undefined restarts".
+  const status = parseSpawnerStatus(JSON.stringify({ phase: "running" }));
+
+  assert.equal(status.restartCount, 0);
+});
+
+test("rejectsAnUnknownPhaseRatherThanTrustingIt", () => {
+  // An unrecognized phase means this client is older than the spawner. Showing
+  // nothing is safer than rendering a phase whose meaning we do not know.
+  assert.equal(
+    parseSpawnerStatus(JSON.stringify({ phase: "definitely-not-a-phase" })),
+    null,
+  );
+});
+
+test("rejectsMalformedOrEmptyContent", () => {
+  assert.equal(parseSpawnerStatus(""), null);
+  assert.equal(parseSpawnerStatus("   "), null);
+  assert.equal(parseSpawnerStatus("not json at all"), null);
+  assert.equal(parseSpawnerStatus("{}"), null);
+});
+
+test("readsTheSpecSlugFromTheDTag", () => {
+  assert.equal(specSlugFromEvent(statusEvent("{}")), "fizz-prod");
+});
+
+test("returnsNullWhenTheDTagIsMissingOrValueless", () => {
+  assert.equal(specSlugFromEvent(statusEvent("{}", [])), null);
+  assert.equal(specSlugFromEvent(statusEvent("{}", [["p", "x"]])), null);
+  // A bare ["d"] carries no slug and must not read as an empty-string slug.
+  assert.equal(specSlugFromEvent(statusEvent("{}", [["d"]])), null);
+});
+
+const announcementEvent = (content) => ({
+  id: "a".repeat(64),
+  pubkey: "b".repeat(64),
+  created_at: 1_700_000_000,
+  kind: 10180,
+  tags: [],
+  content: typeof content === "string" ? content : JSON.stringify(content),
+  sig: "f".repeat(128),
+});
+
+test("takesTheAnnouncingPubkeyFromTheEnvelopeNotTheContent", () => {
+  // A spawner could otherwise claim to be a different pubkey and get itself
+  // connected in place of the real one.
+  const a = parseSpawnerAnnouncement(
+    announcementEvent({
+      name: "prod-vps",
+      max_agents: 16,
+      agents_running: 3,
+      pubkey: "c".repeat(64),
+    }),
+  );
+
+  assert.equal(a.pubkey, "b".repeat(64));
+  assert.equal(a.name, "prod-vps");
+  assert.equal(a.maxAgents, 16);
+  assert.equal(a.agentsRunning, 3);
+});
+
+test("mapsSnakeCaseCapacityAndImageFields", () => {
+  const a = parseSpawnerAnnouncement(
+    announcementEvent({
+      name: "gpu-box",
+      description: "4090, Helsinki",
+      agent_image: "ghcr.io/block/buzz-acp:main",
+      max_agents: 4,
+      agents_running: 0,
+      max_cpu_millis: 8000,
+      max_memory_mib: 32768,
+    }),
+  );
+
+  assert.equal(a.description, "4090, Helsinki");
+  assert.equal(a.agentImage, "ghcr.io/block/buzz-acp:main");
+  assert.equal(a.maxCpuMillis, 8000);
+  assert.equal(a.maxMemoryMib, 32768);
+});
+
+test("rejectsANamelessAnnouncement", () => {
+  // A blank row in a spawner picker is worse than no row.
+  assert.equal(
+    parseSpawnerAnnouncement(
+      announcementEvent({ name: "   ", max_agents: 1, agents_running: 0 }),
+    ),
+    null,
+  );
+  assert.equal(
+    parseSpawnerAnnouncement(announcementEvent({ max_agents: 1 })),
+    null,
+  );
+});
+
+test("rejectsMalformedAnnouncementContent", () => {
+  assert.equal(parseSpawnerAnnouncement(announcementEvent("")), null);
+  assert.equal(parseSpawnerAnnouncement(announcementEvent("not json")), null);
+});
+
+test("defaultsMissingCapacityToZeroRatherThanUndefined", () => {
+  // Rendered as "x/y agents"; undefined would surface to the user as text.
+  const a = parseSpawnerAnnouncement(announcementEvent({ name: "vps" }));
+  assert.equal(a.maxAgents, 0);
+  assert.equal(a.agentsRunning, 0);
+});
+
+test("emptyStatusContentParsesAsNullSoTombstonesAreDistinguishable", () => {
+  // The store relies on this: empty content means "agent deleted", and must be
+  // told apart from malformed content, which means "ignore this event".
+  assert.equal(parseSpawnerStatus(""), null);
+  assert.equal(parseSpawnerStatus("   "), null);
+});
diff --git a/desktop/src/shared/api/spawnerRelay.ts b/desktop/src/shared/api/spawnerRelay.ts
new file mode 100644
index 0000000000..3a9901a03b
--- /dev/null
+++ b/desktop/src/shared/api/spawnerRelay.ts
@@ -0,0 +1,332 @@
+import { signRelayEvent } from "@/shared/api/tauri";
+import type { RelayEvent } from "@/shared/api/types";
+import {
+  KIND_SPAWNER_AGENT_SPEC,
+  KIND_SPAWNER_AGENT_STATUS,
+  KIND_SPAWNER_ANNOUNCEMENT,
+  KIND_SPAWNER_ATTESTATION,
+} from "@/shared/constants/kinds";
+import { relayClient } from "./relayClient";
+
+/**
+ * Lookback for the attestation subscription.
+ *
+ * Kind 24201 is ephemeral, so the relay never replays it — `since` only bounds
+ * what a reconnect can surface. Kept short because a spawner re-sends its
+ * request when the previous round times out; a stale frame the owner already
+ * answered would just prompt them twice for nothing.
+ */
+const ATTESTATION_LOOKBACK_SECS = 120;
+
+/** A spawner advertising itself, from kind:10180. */
+export type SpawnerAnnouncement = {
+  /** Announcing pubkey — the verified event author, not a content field. */
+  pubkey: string;
+  name: string;
+  description?: string;
+  agentImage?: string;
+  /** ACP agent binary the host runs. Display-only; the host decides, not us. */
+  runtime?: string;
+  maxAgents: number;
+  agentsRunning: number;
+  maxCpuMillis?: number;
+  maxMemoryMib?: number;
+};
+
+/**
+ * Subscribe to spawner announcements in this community.
+ *
+ * Unfiltered by author: discovery is the point. Everything in the content is
+ * self-reported and must be treated as a hint — a spawner appearing here is not
+ * trusted, and running an agent on it still requires the owner to sign an
+ * attestation.
+ */
+export function subscribeToSpawnerAnnouncements(
+  onEvent: (event: RelayEvent) => void,
+) {
+  return relayClient.subscribeLive(
+    { kinds: [KIND_SPAWNER_ANNOUNCEMENT], limit: 100 },
+    onEvent,
+  );
+}
+
+/** Parse a kind:10180 event, returning null when unusable. */
+export function parseSpawnerAnnouncement(
+  event: RelayEvent,
+): SpawnerAnnouncement | null {
+  if (!event.content.trim()) return null;
+  try {
+    const raw = JSON.parse(event.content) as Record<string, unknown>;
+    const name = typeof raw.name === "string" ? raw.name.trim() : "";
+    // A nameless spawner would render as a blank row, which is worse than not
+    // listing it at all.
+    if (!name) return null;
+    return {
+      // Identity comes from the signed envelope, never from content.
+      pubkey: event.pubkey,
+      name,
+      description: asOptionalString(raw.description),
+      agentImage: asOptionalString(raw.agent_image),
+      runtime: asOptionalString(raw.runtime),
+      maxAgents: typeof raw.max_agents === "number" ? raw.max_agents : 0,
+      agentsRunning:
+        typeof raw.agents_running === "number" ? raw.agents_running : 0,
+      maxCpuMillis:
+        typeof raw.max_cpu_millis === "number" ? raw.max_cpu_millis : undefined,
+      maxMemoryMib:
+        typeof raw.max_memory_mib === "number" ? raw.max_memory_mib : undefined,
+    };
+  } catch {
+    return null;
+  }
+}
+
+/** The reconciliation phase a spawner reports, mirroring `SpawnPhase` in Rust. */
+export type SpawnPhase =
+  | "pending_attestation"
+  | "starting"
+  | "running"
+  | "failed"
+  | "stopped";
+
+/** Content body of a kind:30179 status event. */
+export type SpawnerAgentStatus = {
+  phase: SpawnPhase;
+  agentPubkey?: string;
+  specHash?: string;
+  error?: string;
+  restartCount: number;
+};
+
+/** Inbound author gate for a spawned agent, mirroring `RespondTo` in Rust. */
+export type SpawnerRespondTo = "anyone" | "owner-only" | "allowlist";
+
+/** Content body of a kind:30178 spec event. */
+export type SpawnerAgentSpec = {
+  name: string;
+  /**
+   * Existing agent to relocate, instead of minting a fresh key.
+   *
+   * The **public** key only — never a secret. Setting it tells the spawner
+   * "this is an agent that already exists"; the secret is asked for separately
+   * over the encrypted kind:24201 handshake, which is the only channel that can
+   * carry it. A kind:30178 spec is world-readable, so a secret here would be
+   * published to the whole community.
+   *
+   * Identity has to be preserved because the pubkey *is* the agent: its channel
+   * membership, its kind:0 profile, the `d` tag of its kind:30177 record, its
+   * DMs, and NIP-AE memory whose `d` tags derive from
+   * `conversation_key(agent_seckey, owner_pubkey)`. A new key would leave that
+   * memory permanently undecryptable.
+   */
+  agentPubkey?: string;
+  personaId?: string;
+  systemPrompt?: string;
+  model?: string;
+  provider?: string;
+  parallelism: number;
+  respondTo: SpawnerRespondTo;
+  respondToAllowlist?: string[];
+  resources?: { cpuMillis?: number; memoryMib?: number };
+  enabled: boolean;
+};
+
+/**
+ * Subscribe to attestation frames addressed to this owner.
+ *
+ * The relay routes kind:24201 on the global `#p`-kind index and enforces a
+ * single `p` tag at ingest, so this filter receives one owner's handshakes and
+ * nobody else's. The payload is still NIP-44 ciphertext — decryption happens in
+ * Rust, where the owner's secret key lives.
+ */
+export function subscribeToSpawnerAttestations(
+  ownerPubkey: string,
+  onEvent: (event: RelayEvent) => void,
+) {
+  return relayClient.subscribeLive(
+    {
+      kinds: [KIND_SPAWNER_ATTESTATION],
+      "#p": [ownerPubkey],
+      limit: 100,
+      since: Math.floor(Date.now() / 1_000) - ATTESTATION_LOOKBACK_SECS,
+    },
+    onEvent,
+  );
+}
+
+/**
+ * Subscribe to status events for this owner's server-hosted agents.
+ *
+ * Filtered by `#p` rather than by author: an owner may use more than one
+ * spawner, and each tags the owner on the status events it publishes. Because
+ * status is NIP-33 addressed by `(pubkey, kind, d_tag)`, an impostor's event
+ * lands at their own address and cannot overwrite a real one — but it can still
+ * reach this subscription, so callers must check `event.pubkey` against a
+ * spawner they trust before believing it.
+ */
+export function subscribeToSpawnerStatus(
+  ownerPubkey: string,
+  onEvent: (event: RelayEvent) => void,
+) {
+  return relayClient.subscribeLive(
+    { kinds: [KIND_SPAWNER_AGENT_STATUS], "#p": [ownerPubkey], limit: 200 },
+    onEvent,
+  );
+}
+
+/**
+ * Publish (or replace) an agent spec addressed to `spawnerPubkey`.
+ *
+ * The `d` tag is the caller-chosen slug and is the stable handle for this agent
+ * across edits — it cannot be the agent pubkey, which does not exist until the
+ * spawner mints one.
+ */
+export async function publishSpawnerAgentSpec(input: {
+  slug: string;
+  spawnerPubkey: string;
+  spec: SpawnerAgentSpec;
+}): Promise<void> {
+  await relayClient.preconnect();
+  const event = await signRelayEvent({
+    kind: KIND_SPAWNER_AGENT_SPEC,
+    content: JSON.stringify(toWireSpec(input.spec)),
+    tags: [
+      ["d", input.slug],
+      ["spawner", input.spawnerPubkey],
+      ["p", input.spawnerPubkey],
+    ],
+  });
+  await relayClient.publishEvent(
+    event,
+    "Timed out publishing the server agent.",
+    "Failed to publish the server agent.",
+  );
+}
+
+/**
+ * Tear a server agent down permanently.
+ *
+ * Publishes an empty replacement rather than a kind:5 deletion: kind 30178 is
+ * parameterized-replaceable, so a deletion leaves nothing for the spawner to
+ * fan out and it would never learn the agent should stop. An emptied
+ * replacement is the tombstone the spawner watches for, and it removes the
+ * container, the volume, and the agent's key.
+ *
+ * To stop an agent while keeping its identity, publish the spec with
+ * `enabled: false` instead.
+ */
+export async function deleteSpawnerAgentSpec(input: {
+  slug: string;
+  spawnerPubkey: string;
+}): Promise<void> {
+  await relayClient.preconnect();
+  const event = await signRelayEvent({
+    kind: KIND_SPAWNER_AGENT_SPEC,
+    content: "",
+    tags: [
+      ["d", input.slug],
+      ["spawner", input.spawnerPubkey],
+      ["p", input.spawnerPubkey],
+    ],
+  });
+  await relayClient.publishEvent(
+    event,
+    "Timed out removing the server agent.",
+    "Failed to remove the server agent.",
+  );
+}
+
+/**
+ * Publish an attestation answer over the live WebSocket.
+ *
+ * Ephemeral kinds must not go through `POST /events`: that path runs the ingest
+ * pipeline, whose per-kind scope allowlist covers stored kinds only and rejects
+ * kind 24201 with `restricted: unknown event kind`. The relay's ephemeral
+ * routing — and the `#p` gate that delivers the frame to exactly one
+ * counterparty — lives in the WebSocket handler. Kind 24200 observer control
+ * frames take the same route for the same reason.
+ */
+export async function respondToSpawnerAttestation(
+  event: RelayEvent,
+): Promise<void> {
+  await relayClient.preconnect();
+  await relayClient.publishEvent(
+    event,
+    "Timed out authorizing the server agent.",
+    "Failed to authorize the server agent.",
+  );
+}
+
+/** Parse a kind:30179 content body, returning null when it is unusable. */
+export function parseSpawnerStatus(content: string): SpawnerAgentStatus | null {
+  if (!content.trim()) return null;
+  try {
+    const raw = JSON.parse(content) as Record<string, unknown>;
+    const phase = raw.phase;
+    if (!isSpawnPhase(phase)) return null;
+    return {
+      phase,
+      agentPubkey: asOptionalString(raw.agent_pubkey),
+      specHash: asOptionalString(raw.spec_hash),
+      error: asOptionalString(raw.error),
+      restartCount:
+        typeof raw.restart_count === "number" ? raw.restart_count : 0,
+    };
+  } catch {
+    return null;
+  }
+}
+
+/** Read the `d` tag (spec slug) from a spawner event. */
+export function specSlugFromEvent(event: RelayEvent): string | null {
+  const tag = event.tags.find((t) => t[0] === "d" && t.length >= 2);
+  return tag?.[1] ?? null;
+}
+
+/**
+ * Convert the camelCase UI shape to the snake_case wire shape serde expects.
+ *
+ * Optional fields are omitted rather than sent as null: the Rust projection
+ * uses `skip_serializing_if`, and an explicit null would fail to deserialize
+ * into `Option<String>` on some fields while silently widening the wire
+ * contract on others.
+ */
+function toWireSpec(spec: SpawnerAgentSpec): Record<string, unknown> {
+  const wire: Record<string, unknown> = {
+    name: spec.name,
+    parallelism: spec.parallelism,
+    respond_to: spec.respondTo,
+    enabled: spec.enabled,
+  };
+  if (spec.agentPubkey) wire.agent_pubkey = spec.agentPubkey;
+  if (spec.personaId) wire.persona_id = spec.personaId;
+  if (spec.systemPrompt) wire.system_prompt = spec.systemPrompt;
+  if (spec.model) wire.model = spec.model;
+  if (spec.provider) wire.provider = spec.provider;
+  if (spec.respondToAllowlist?.length) {
+    wire.respond_to_allowlist = spec.respondToAllowlist;
+  }
+  if (spec.resources) {
+    const resources: Record<string, unknown> = {};
+    if (spec.resources.cpuMillis)
+      resources.cpu_millis = spec.resources.cpuMillis;
+    if (spec.resources.memoryMib)
+      resources.memory_mib = spec.resources.memoryMib;
+    if (Object.keys(resources).length) wire.resources = resources;
+  }
+  return wire;
+}
+
+function isSpawnPhase(value: unknown): value is SpawnPhase {
+  return (
+    value === "pending_attestation" ||
+    value === "starting" ||
+    value === "running" ||
+    value === "failed" ||
+    value === "stopped"
+  );
+}
+
+function asOptionalString(value: unknown): string | undefined {
+  return typeof value === "string" && value.length > 0 ? value : undefined;
+}
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index c57525480e..a870e703e5 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -152,6 +152,7 @@ export type RawManagedAgent = {
   log_path: string;
   start_on_app_launch: boolean;
   auto_restart_on_config_change?: boolean;
+  relocated_to_spawner?: string | null;
   backend: ManagedAgentBackend;
   backend_agent_id: string | null;
   // Optional: pre-feature mock fixtures may omit these. Mapped to
@@ -733,6 +734,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
     logPath: agent.log_path,
     startOnAppLaunch: agent.start_on_app_launch,
     autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true,
+    relocatedToSpawner: agent.relocated_to_spawner ?? null,
     backend: agent.backend,
     backendAgentId: agent.backend_agent_id,
     // Fallbacks for pre-feature mocks/fixtures that don't carry these fields.
diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts
index c74b099f88..d6ac54cc75 100644
--- a/desktop/src/shared/api/tauriManagedAgents.ts
+++ b/desktop/src/shared/api/tauriManagedAgents.ts
@@ -50,6 +50,27 @@ export async function setManagedAgentAutoRestart(
   return fromRawManagedAgent(response);
 }
 
+/**
+ * Record where a managed agent's identity lives.
+ *
+ * Pass the spawner pubkey right after a successful attestation hand-off, or
+ * `null` to move the agent back to this device. The backend refuses every
+ * local start (manual, restore, reconcile, auto-restart) while it is set.
+ */
+export async function setManagedAgentRelocated(
+  pubkey: string,
+  relocatedToSpawner: string | null,
+): Promise<ManagedAgent> {
+  const response = await invokeTauri<RawManagedAgent>(
+    "set_managed_agent_relocated",
+    {
+      pubkey,
+      relocatedToSpawner,
+    },
+  );
+  return fromRawManagedAgent(response);
+}
+
 export async function listManagedAgentRuntimes(): Promise<
   ManagedAgentRuntimeStatus[]
 > {
diff --git a/desktop/src/shared/api/tauriSpawner.ts b/desktop/src/shared/api/tauriSpawner.ts
new file mode 100644
index 0000000000..f5aa1a2b7e
--- /dev/null
+++ b/desktop/src/shared/api/tauriSpawner.ts
@@ -0,0 +1,101 @@
+import { invokeTauri } from "@/shared/api/tauri";
+import type { RelayEvent } from "@/shared/api/types";
+
+/// The owner's trust decision for a spawner pubkey, as the Rust side spells it.
+export type SpawnerTrust = "trusted" | "untrusted";
+
+/**
+ * Prompt material sent to a spawner over the encrypted handshake.
+ *
+ * Travels here rather than on the kind:30178 spec because specs are
+ * world-readable: inlining a system prompt there — or marking the persona
+ * `["shared","true"]` — would publish it to the whole community. Snake_case to
+ * match the Rust `PromptMaterial` it deserializes into.
+ */
+export type SpawnerPromptMaterial = {
+  system_prompt?: string;
+  team_instructions?: string;
+  model?: string;
+  provider?: string;
+};
+
+/// A decrypted kind:24201 attestation request, ready to show the user.
+export type SpawnerAttestationRequest = {
+  spawnerPubkey: string;
+  specSlug: string;
+  agentPubkey: string;
+  conditions: string;
+  nonce: string;
+};
+
+/**
+ * Decrypt an inbound attestation frame so the UI can show what is being asked.
+ *
+ * Returns `null` for frames that are not requests — a response or rejection is
+ * the owner's own outbound traffic echoed back off the ephemeral stream, and
+ * must not raise a second prompt.
+ */
+export async function decodeSpawnerAttestation(
+  spawnerPubkey: string,
+  encryptedContent: string,
+): Promise<SpawnerAttestationRequest | null> {
+  const result = await invokeTauri<SpawnerAttestationRequest | null>(
+    "decode_spawner_attestation",
+    { spawnerPubkey, encryptedContent },
+  );
+  return result ?? null;
+}
+
+/**
+ * Build the signed answer to an attestation request.
+ *
+ * The ciphertext is passed back rather than the decoded fields: Rust
+ * re-decrypts and signs over the agent pubkey *it* reads from the frame, so a
+ * renderer bug cannot substitute a different key between the prompt the user
+ * saw and the tag that gets signed.
+ *
+ * Returns the signed event for the caller to publish over the WebSocket — see
+ * `respondToSpawnerAttestation` in `spawnerRelay.ts` for why HTTP will not do.
+ */
+export async function buildSpawnerAttestationResponse(input: {
+  spawnerPubkey: string;
+  encryptedContent: string;
+  trust: SpawnerTrust;
+  rejectReason?: string;
+  prompt?: SpawnerPromptMaterial;
+}): Promise<SpawnerAttestationResponse> {
+  const raw = await invokeTauri<RawSpawnerAttestationResponse>(
+    "respond_to_spawner_attestation",
+    {
+      spawnerPubkey: input.spawnerPubkey,
+      encryptedContent: input.encryptedContent,
+      trust: input.trust,
+      rejectReason: input.rejectReason ?? null,
+      prompt: input.prompt ?? null,
+    },
+  );
+  return {
+    event: JSON.parse(raw.eventJson) as RelayEvent,
+    relocatedAgentPubkey: raw.relocatedAgentPubkey ?? null,
+  };
+}
+
+/** Wire shape of `respond_to_spawner_attestation`. */
+type RawSpawnerAttestationResponse = {
+  eventJson: string;
+  relocatedAgentPubkey?: string | null;
+};
+
+/**
+ * The signed answer, plus whether it handed over an existing agent's key.
+ *
+ * `relocatedAgentPubkey` is set by Rust — not by the renderer — when the
+ * requested agent pubkey turned out to be a locally managed agent and its nsec
+ * was included in the encrypted response. That makes it the authoritative
+ * signal that this agent now lives on the server, and the caller must stop the
+ * local process: two processes on one key both answer every mention.
+ */
+export type SpawnerAttestationResponse = {
+  event: RelayEvent;
+  relocatedAgentPubkey: string | null;
+};
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 689c400b03..611fc1af34 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -389,6 +389,9 @@ export type ManagedAgent = {
   logPath: string;
   startOnAppLaunch: boolean;
   autoRestartOnConfigChange: boolean;
+  /** Spawner pubkey this identity was handed to, or null when it runs here.
+   * A relocated agent must never be started locally (split-brain guard). */
+  relocatedToSpawner: string | null;
   backend: ManagedAgentBackend;
   backendAgentId: string | null;
   /** Who the agent should respond to. Maps to `buzz-acp --respond-to`. */
diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts
index ef3234f4c5..b36e220716 100644
--- a/desktop/src/shared/constants/kinds.ts
+++ b/desktop/src/shared/constants/kinds.ts
@@ -54,6 +54,20 @@ export const KIND_TEAM = 30176;
 export const KIND_MANAGED_AGENT = 30177;
 export const KIND_USER_STATUS = 30315;
 export const KIND_AGENT_OBSERVER_FRAME = 24200;
+
+// NIP-AS agent spawner. Mirror of buzz-core's KIND_SPAWNER_* constants.
+// 30178 is the owner-authored desired state for a server-hosted agent
+// (d-tag = client-chosen spec slug); 30179 is the spawner-authored status for
+// that same slug; 24201 is the ephemeral, NIP-44 encrypted attestation
+// handshake that lets an owner sign a NIP-OA tag for a key the spawner minted.
+// 10180 is the spawner's self-announcement, keyed by (pubkey, kind). It exists
+// so a user can pick a spawner from a list instead of pasting 64 hex
+// characters. Listing one implies nothing about trust — authorization is the
+// per-agent attestation the owner signs.
+export const KIND_SPAWNER_ANNOUNCEMENT = 10180;
+export const KIND_SPAWNER_AGENT_SPEC = 30178;
+export const KIND_SPAWNER_AGENT_STATUS = 30179;
+export const KIND_SPAWNER_ATTESTATION = 24201;
 export const KIND_AGENT_TURN_METRIC = 44200;
 export const KIND_EVENT_REMINDER = 30300;
 export const KIND_REPO_ANNOUNCEMENT = 30617;
diff --git a/desktop/tests/e2e/server-agents-screenshots.spec.ts b/desktop/tests/e2e/server-agents-screenshots.spec.ts
new file mode 100644
index 0000000000..259f59806c
--- /dev/null
+++ b/desktop/tests/e2e/server-agents-screenshots.spec.ts
@@ -0,0 +1,138 @@
+import { expect, test } from "@playwright/test";
+
+import { waitForAnimations } from "../helpers/animations";
+import { installMockBridge } from "../helpers/bridge";
+
+/**
+ * Navigate to the Agents screen the way a user does.
+ *
+ * The app is not hash-routed at load time — going straight to `/#/agents`
+ * races the bridge and the community gate, so the app renders its
+ * "Community connection failed" screen instead. Wait for the mocked invoke
+ * bridge, then click through, matching `agents.spec.ts`.
+ */
+async function gotoAgents(page: import("@playwright/test").Page) {
+  await page.goto("/", { waitUntil: "domcontentloaded" });
+  await page.waitForFunction(() => {
+    const w = window as Window & {
+      __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
+      __TAURI_INTERNALS__?: { invoke?: unknown };
+    };
+    return (
+      typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
+      typeof w.__TAURI_INTERNALS__?.invoke === "function"
+    );
+  });
+  await page.getByTestId("open-agents-view").click();
+  await expect(
+    page.locator("section", { hasText: "Server agents" }),
+  ).toBeVisible({ timeout: 15_000 });
+}
+
+// A spawner pubkey the user has already pointed this device at. Seeded into
+// localStorage so the section renders its configured state rather than the
+// setup card.
+const SPAWNER = "5c".repeat(32);
+
+const SCREENSHOT_DIR = "test-results/server-agents";
+
+test.describe("server agents section", () => {
+  test("explains that a spawner need not be the relay machine", async ({
+    page,
+  }) => {
+    await installMockBridge(page);
+    await gotoAgents(page);
+
+    const section = page.locator("section", { hasText: "Server agents" });
+    await expect(section).toBeVisible();
+    // The empty state is where the feature is discovered, so it has to explain
+    // itself rather than just showing an input.
+    await expect(section).toContainText("keep working when Buzz is closed");
+    // The location-independence point users ask about first.
+    await expect(section).toContainText(
+      "does not have to be the relay machine",
+    );
+    await expect(
+      section.getByPlaceholder("Spawner public key (64 hex characters)"),
+    ).toBeVisible();
+
+    await waitForAnimations(page);
+    await section.screenshot({ path: `${SCREENSHOT_DIR}/01-setup-card.png` });
+  });
+
+  test("rejects a malformed spawner public key", async ({ page }) => {
+    await installMockBridge(page);
+    await gotoAgents(page);
+
+    const section = page.locator("section", { hasText: "Server agents" });
+    await section
+      .getByPlaceholder("Spawner public key (64 hex characters)")
+      .fill("not-a-pubkey");
+    await section.getByRole("button", { name: "Connect" }).click();
+
+    // Fails closed: the section must stay on the setup card rather than storing
+    // a value the relay could never route to.
+    await expect(
+      section.getByPlaceholder("Spawner public key (64 hex characters)"),
+    ).toBeVisible();
+  });
+
+  test("renders the configured state with a deploy menu", async ({ page }) => {
+    // localStorage must be seeded BEFORE the bridge installs — React reads the
+    // store on mount and the bridge is what triggers mount.
+    await page.addInitScript((spawner) => {
+      window.localStorage.setItem(
+        "buzz:spawner-pubkeys",
+        JSON.stringify([spawner]),
+      );
+    }, SPAWNER);
+    await installMockBridge(page);
+    await gotoAgents(page);
+
+    const section = page.locator("section", { hasText: "Server agents" });
+    await expect(section).toBeVisible();
+    await expect(section).toContainText("Disconnect");
+    await expect(section).toContainText("No agents here yet");
+
+    const deploy = section.getByRole("button", { name: "Deploy agent" });
+    await expect(deploy).toBeEnabled();
+
+    await waitForAnimations(page);
+    await section.screenshot({
+      path: `${SCREENSHOT_DIR}/02-configured.png`,
+    });
+
+    // The menu offers the built-in personas, which is how an agent gets onto
+    // the server at all.
+    await deploy.click();
+    const menu = page.getByRole("menu");
+    await expect(menu).toBeVisible();
+    await expect(menu).toContainText("Fizz");
+    await waitForAnimations(page);
+    await page.screenshot({
+      path: `${SCREENSHOT_DIR}/03-deploy-menu.png`,
+      clip: { x: 300, y: 1200, width: 980, height: 620 },
+    });
+  });
+
+  test("lets the user disconnect a spawner", async ({ page }) => {
+    await page.addInitScript((spawner) => {
+      window.localStorage.setItem(
+        "buzz:spawner-pubkeys",
+        JSON.stringify([spawner]),
+      );
+    }, SPAWNER);
+    await installMockBridge(page);
+    await gotoAgents(page);
+
+    const section = page.locator("section", { hasText: "Server agents" });
+    await section.getByRole("button", { name: "Disconnect" }).click();
+
+    // Disconnecting is local only: the connect field stays, and the deploy
+    // action disappears because there is nowhere to deploy to.
+    await expect(
+      section.getByPlaceholder("Spawner public key (64 hex characters)"),
+    ).toBeVisible();
+    await expect(section).not.toContainText("Disconnect");
+  });
+});
diff --git a/run-dev.command b/run-dev.command
new file mode 100755
index 0000000000..2c9a03ee29
--- /dev/null
+++ b/run-dev.command
@@ -0,0 +1,8 @@
+#!/bin/zsh
+# Launches Buzz dev in the Aqua (GUI) session so the app gets keyboard focus.
+# Claude Code / tmux shells run in a Background launchd session — apps launched
+# there can never become the active app. See memory: gui-apps-need-aqua-session.
+cd "$(dirname "$0")"
+echo "session: $(launchctl managername)"   # should print Aqua
+. ./bin/activate-hermit
+exec just dev

From 576dc73f95d5a7ba991327e471dbe2aa1fbb709f Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 00:54:08 +0530
Subject: [PATCH 02/16] fix: integrate rebase onto main (global config arg,
 relocation field in fixtures)

Signed-off-by: sid <siddhartpai@gmail.com>
---
 desktop/src-tauri/src/commands/agent_settings.rs          | 8 +++++++-
 .../src/managed_agents/effective_config/tests.rs          | 1 +
 .../src-tauri/src/managed_agents/persona_events/tests.rs  | 1 +
 3 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs
index 9ef4408147..1fb580b32d 100644
--- a/desktop/src-tauri/src/commands/agent_settings.rs
+++ b/desktop/src-tauri/src/commands/agent_settings.rs
@@ -115,7 +115,13 @@ pub async fn set_managed_agent_relocated(
             .find(|record| record.pubkey == pubkey)
             .ok_or_else(|| format!("agent {pubkey} not found"))?;
         let personas = load_personas(&app).unwrap_or_default();
-        build_managed_agent_summary(&app, record, &runtimes, &personas)
+        build_managed_agent_summary(
+            &app,
+            record,
+            &runtimes,
+            &personas,
+            &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
+        )
     })
     .await
     .map_err(|e| format!("spawn_blocking failed: {e}"))?
diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs
index c8e437809c..ae6469ac52 100644
--- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs
@@ -59,6 +59,7 @@ fn record(
         model: model.map(str::to_string),
         provider: provider.map(str::to_string),
         persona_source_version: None,
+        relocated_to_spawner: None,
         env_vars: BTreeMap::new(),
         start_on_app_launch: false,
         runtime_pid: None,
diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs
index b9542f9a87..6a4ef311cf 100644
--- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs
@@ -25,6 +25,7 @@ fn sample_record() -> ManagedAgentRecord {
         model: None,
         provider: None,
         persona_source_version: None,
+        relocated_to_spawner: None,
         env_vars: BTreeMap::new(),
         start_on_app_launch: false,
         auto_restart_on_config_change: true,

From 7f88e51f0848e799a224c3cc1928a93f78f7efeb Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 00:33:06 +0530
Subject: [PATCH 03/16] fix(spawner): reclaim agent-container names squatted by
 a retired spawner identity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Container names are owner+slug scoped, but list() only sees containers
labelled with the current spawner pubkey. A container created by a
previous identity (wiped state dir, re-minted nsec) is therefore
invisible to reconcile while still holding the name, and every create
for that slug fails with Docker 409 forever.

On a 409, inspect the squatter: if it carries the com.buzz.agent label
it is spawner-managed (whichever identity created it) — remove it and
retry the create once. Unlabelled containers are refused, so an
operator's unrelated container of the same name is never touched. The
workspace volume is a named mount and survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
---
 crates/buzz-spawner/src/container.rs | 98 +++++++++++++++++++++++++++-
 1 file changed, 95 insertions(+), 3 deletions(-)

diff --git a/crates/buzz-spawner/src/container.rs b/crates/buzz-spawner/src/container.rs
index cc3b2123a4..cb7adf5c68 100644
--- a/crates/buzz-spawner/src/container.rs
+++ b/crates/buzz-spawner/src/container.rs
@@ -278,17 +278,51 @@ impl DockerOps {
             ..Default::default()
         };
 
-        let created = self
+        let created = match self
             .docker
             .create_container(
                 Some(CreateContainerOptions {
                     name: Some(spec.name.clone()),
                     ..Default::default()
                 }),
-                body,
+                body.clone(),
             )
             .await
-            .with_context(|| format!("failed to create container {}", spec.name))?;
+        {
+            Ok(created) => created,
+            // The name can be squatted by a container a previous spawner
+            // identity created (names are owner+slug scoped, but list() only
+            // sees the current identity's label) — self-heal by removing it,
+            // but only after verifying the labels say it is an agent container.
+            Err(e) if is_name_conflict(&e) => {
+                self.remove_name_squatter(&spec.name)
+                    .await
+                    .with_context(|| {
+                        format!(
+                            "container name {} is taken and could not be reclaimed",
+                            spec.name
+                        )
+                    })?;
+                self.docker
+                    .create_container(
+                        Some(CreateContainerOptions {
+                            name: Some(spec.name.clone()),
+                            ..Default::default()
+                        }),
+                        body,
+                    )
+                    .await
+                    .with_context(|| {
+                        format!(
+                            "failed to create container {} after reclaiming its name",
+                            spec.name
+                        )
+                    })?
+            }
+            Err(e) => {
+                return Err(e).with_context(|| format!("failed to create container {}", spec.name));
+            }
+        };
 
         self.docker
             .start_container(&created.id, None::<StartContainerOptions>)
@@ -338,6 +372,39 @@ impl DockerOps {
         Ok(())
     }
 
+    /// Remove an agent container squatting on `name`.
+    ///
+    /// Only containers carrying the `com.buzz.agent` label are removed — that
+    /// label marks them as spawner-managed regardless of which spawner
+    /// identity created them, so orphans left by a retired identity are fair
+    /// game while an operator's unrelated container of the same name is not.
+    /// The workspace volume is a named mount and survives the removal.
+    async fn remove_name_squatter(&self, name: &str) -> Result<()> {
+        use bollard::query_parameters::InspectContainerOptions;
+
+        let squatter = self
+            .docker
+            .inspect_container(name, None::<InspectContainerOptions>)
+            .await
+            .with_context(|| format!("failed to inspect conflicting container {name}"))?;
+        let labels = squatter.config.as_ref().and_then(|c| c.labels.as_ref());
+        if !labels.is_some_and(|labels| labels.contains_key(AGENT_LABEL)) {
+            anyhow::bail!(
+                "conflicting container {name} does not carry the {AGENT_LABEL} label; \
+                 refusing to remove a container the spawner does not manage"
+            );
+        }
+        let spawner = labels
+            .and_then(|labels| labels.get(SPAWNER_LABEL).cloned())
+            .unwrap_or_default();
+        tracing::info!(
+            container = %name,
+            previous_spawner = %spawner,
+            "removing orphaned agent container squatting on required name"
+        );
+        self.remove_inner(name, None).await
+    }
+
     async fn logs_inner(&self, container_id: &str, lines: usize) -> Result<String> {
         use bollard::query_parameters::LogsOptionsBuilder;
         use futures_util::StreamExt;
@@ -362,6 +429,17 @@ impl DockerOps {
     }
 }
 
+/// True when Docker rejected a create because the container name is taken.
+fn is_name_conflict(e: &bollard::errors::Error) -> bool {
+    matches!(
+        e,
+        bollard::errors::Error::DockerResponseServerError {
+            status_code: 409,
+            ..
+        }
+    )
+}
+
 fn is_not_found(e: &bollard::errors::Error) -> bool {
     matches!(
         e,
@@ -395,4 +473,18 @@ mod tests {
         // Without this, two spawners on one host reap each other's containers.
         assert_eq!(labels.get(SPAWNER_LABEL), Some(&"s".repeat(64)));
     }
+
+    #[test]
+    fn name_conflict_matches_409_only() {
+        let conflict = bollard::errors::Error::DockerResponseServerError {
+            status_code: 409,
+            message: "Conflict. The container name is already in use".into(),
+        };
+        assert!(is_name_conflict(&conflict));
+        let not_found = bollard::errors::Error::DockerResponseServerError {
+            status_code: 404,
+            message: "no such container".into(),
+        };
+        assert!(!is_name_conflict(&not_found));
+    }
 }

From b17261c1132b436bfdc3f6d8bb79b98f357b1a57 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 01:23:29 +0530
Subject: [PATCH 04/16] fix(desktop): key server-agent persona lookup by slug,
 not display name

personaByName was keyed by displayName but looked up with agent.slug, so
the persona was only found when the display name happened to equal its
slug. A miss made setEnabled republish the spec with a placeholder prompt
instead of its personaId, silently detaching the agent from its persona.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 .../features/agents/ui/ServerAgentsSection.tsx    | 15 +++++++++++----
 1 file changed, 11 insertions(+), 4 deletions(-)

diff --git a/desktop/src/features/agents/ui/ServerAgentsSection.tsx b/desktop/src/features/agents/ui/ServerAgentsSection.tsx
index 605aedc108..1ab3327095 100644
--- a/desktop/src/features/agents/ui/ServerAgentsSection.tsx
+++ b/desktop/src/features/agents/ui/ServerAgentsSection.tsx
@@ -25,7 +25,7 @@ import {
   useDefaultAgentLocation,
 } from "../agentLocation";
 import { useSpawnerDirectory } from "../spawnerDirectoryStore";
-import { addSpawner, removeSpawner } from "../spawnerPreference";
+import { addSpawner, removeSpawner, slugFromName } from "../spawnerPreference";
 import { useServerAgents, type ServerAgent } from "../useServerAgents";
 import { shortenPubkey } from "./SpawnerAttestationDialog";
 
@@ -105,9 +105,16 @@ export function ServerAgentsSection({ personas }: ServerAgentsSectionProps) {
   } = useServerAgents();
   const directory = useSpawnerDirectory();
 
-  const personaByName = React.useMemo(() => {
+  // Keyed by the slug an agent's spec is published under, because that is the
+  // only name a ServerAgent carries — keying by display name would miss every
+  // persona whose name is not already its own slug ("Fizz" vs "fizz"), and a
+  // miss republishes the spec without its personaId.
+  const personaBySlug = React.useMemo(() => {
     const map = new Map<string, AgentPersona>();
-    for (const persona of personas) map.set(persona.displayName, persona);
+    for (const persona of personas) {
+      const slug = slugFromName(persona.displayName);
+      if (slug) map.set(slug, persona);
+    }
     return map;
   }, [personas]);
 
@@ -208,7 +215,7 @@ export function ServerAgentsSection({ personas }: ServerAgentsSectionProps) {
                         await setEnabled(
                           agent,
                           enabled,
-                          personaByName.get(agent.slug),
+                          personaBySlug.get(agent.slug),
                         );
                       } catch (error) {
                         toast.error(

From b5c27b289a48641ec90c1357efe293c82b270bc3 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 01:45:12 +0530
Subject: [PATCH 05/16] test(desktop): fix server-agents deploy-menu spec

Two failures, both of which made this spec fail at head:

- The screenshot clip (y=1200) fell entirely outside the 720px viewport,
  which Playwright rejects outright rather than cropping. Derive the clip
  from the menu's own bounding box and clamp it to the viewport.
- The mock bridge seeds no personas, so libraryPersonas was empty and the
  Deploy action was correctly disabled. Seed an active Fizz persona, which
  is what the menu assertion already expected to find.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 .../e2e/server-agents-screenshots.spec.ts     | 28 +++++++++++++++++--
 1 file changed, 26 insertions(+), 2 deletions(-)

diff --git a/desktop/tests/e2e/server-agents-screenshots.spec.ts b/desktop/tests/e2e/server-agents-screenshots.spec.ts
index 259f59806c..43b6c26154 100644
--- a/desktop/tests/e2e/server-agents-screenshots.spec.ts
+++ b/desktop/tests/e2e/server-agents-screenshots.spec.ts
@@ -86,7 +86,17 @@ test.describe("server agents section", () => {
         JSON.stringify([spawner]),
       );
     }, SPAWNER);
-    await installMockBridge(page);
+    // The deploy menu offers *active* personas, so one has to exist for the
+    // action to be enabled at all — the mock bridge seeds none by default.
+    await installMockBridge(page, {
+      personas: [
+        {
+          displayName: "Fizz",
+          isActive: true,
+          systemPrompt: "You are Fizz.",
+        },
+      ],
+    });
     await gotoAgents(page);
 
     const section = page.locator("section", { hasText: "Server agents" });
@@ -109,9 +119,23 @@ test.describe("server agents section", () => {
     await expect(menu).toBeVisible();
     await expect(menu).toContainText("Fizz");
     await waitForAnimations(page);
+    // Clip around where the menu actually opened, clamped to the viewport —
+    // a fixed offset drifts as the page above the section grows, and a clip
+    // outside the viewport is a hard Playwright error, not an empty image.
+    const box = await menu.boundingBox();
+    if (!box) throw new Error("deploy menu has no bounding box");
+    const viewport = page.viewportSize() ?? { width: 1280, height: 720 };
+    const pad = 160;
+    const x = Math.max(0, box.x - pad);
+    const y = Math.max(0, box.y - pad / 2);
     await page.screenshot({
       path: `${SCREENSHOT_DIR}/03-deploy-menu.png`,
-      clip: { x: 300, y: 1200, width: 980, height: 620 },
+      clip: {
+        x,
+        y,
+        width: Math.min(box.width + pad * 2, viewport.width - x),
+        height: Math.min(box.height + pad, viewport.height - y),
+      },
     });
   });
 

From 43d69eead810edf25eb74ec1262fa4c7aa4b3d12 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 01:50:59 +0530
Subject: [PATCH 06/16] fix(desktop): clear trusted spawners on community
 switch

resetTrustedSpawners() was defined and documented as "used when switching
identities" but had no call site and was missing from resetCommunityState.
A spawner approved under one identity was therefore auto-approved under the
next: its kind:24201 frame skipped the consent prompt and was signed with a
key the user never vouched for it with, admitting its agent to that
identity's relay membership.

Also records the four spawner singletons this feature added in the
community-switch contract in AGENTS.md, which had drifted.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 AGENTS.md                                     |  5 ++
 .../features/agents/trustedSpawners.test.mjs  | 60 +++++++++++++++++++
 .../features/communities/useCommunityInit.ts  |  6 ++
 3 files changed, 71 insertions(+)
 create mode 100644 desktop/src/features/agents/trustedSpawners.test.mjs

diff --git a/AGENTS.md b/AGENTS.md
index 7ff0eb4d47..88789f3989 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -495,6 +495,11 @@ reconnects preserve pending avatar verification work):
 - `resetAgentObserverStore()` — agent observer relay store
 - `resetActiveAgentTurnsStore()` — active agent turn timers
 - `resetAgentWorkingSignal()` — agent working indicator signal
+- `resetSpawnerAttestationStore()` — pending spawner attestation prompts
+- `resetSpawnerStatusStore()` — server-agent status rows
+- `resetSpawnerDirectoryStore()` — announced spawner directory
+- `resetTrustedSpawners()` — approved-spawner set (identity-scoped: a spawner
+  trusted under the old identity must not auto-sign under the new one)
 - `resetAvatarProfileSync()` — pending verified-avatar profile writes
 - `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts
 - `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state
diff --git a/desktop/src/features/agents/trustedSpawners.test.mjs b/desktop/src/features/agents/trustedSpawners.test.mjs
new file mode 100644
index 0000000000..b306446f86
--- /dev/null
+++ b/desktop/src/features/agents/trustedSpawners.test.mjs
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+// The module reads localStorage at import time, so stub it first.
+const store = new Map();
+globalThis.window = {
+  localStorage: {
+    getItem: (key) => store.get(key) ?? null,
+    setItem: (key, value) => store.set(key, value),
+  },
+};
+
+const { isSpawnerTrusted, resetTrustedSpawners, revokeSpawner, trustSpawner } =
+  await import("./trustedSpawners.ts");
+
+const SPAWNER = "5c".repeat(32);
+const OTHER = "7a".repeat(32);
+
+test("remembersAnApprovedSpawner", () => {
+  resetTrustedSpawners();
+  assert.equal(isSpawnerTrusted(SPAWNER), false);
+  trustSpawner(SPAWNER);
+  assert.equal(isSpawnerTrusted(SPAWNER), true);
+  // Approving one spawner must not vouch for any other.
+  assert.equal(isSpawnerTrusted(OTHER), false);
+});
+
+test("ignoresValuesThatAreNotPubkeys", () => {
+  resetTrustedSpawners();
+  trustSpawner("not-a-pubkey");
+  assert.equal(isSpawnerTrusted("not-a-pubkey"), false);
+});
+
+test("matchesRegardlessOfCase", () => {
+  resetTrustedSpawners();
+  trustSpawner(SPAWNER.toUpperCase());
+  assert.equal(isSpawnerTrusted(SPAWNER), true);
+});
+
+test("revokeStopsAutoApproval", () => {
+  resetTrustedSpawners();
+  trustSpawner(SPAWNER);
+  revokeSpawner(SPAWNER);
+  assert.equal(isSpawnerTrusted(SPAWNER), false);
+});
+
+test("resetClearsEveryDecisionAndThePersistedCopy", () => {
+  // The community-switch path calls this. A spawner trusted under the old
+  // identity must not auto-sign under the new one — and it must not come back
+  // from localStorage on the next read either.
+  resetTrustedSpawners();
+  trustSpawner(SPAWNER);
+  trustSpawner(OTHER);
+
+  resetTrustedSpawners();
+
+  assert.equal(isSpawnerTrusted(SPAWNER), false);
+  assert.equal(isSpawnerTrusted(OTHER), false);
+  assert.deepEqual(JSON.parse(store.get("buzz:trusted-spawners")), []);
+});
diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts
index d983573a41..562a1a9839 100644
--- a/desktop/src/features/communities/useCommunityInit.ts
+++ b/desktop/src/features/communities/useCommunityInit.ts
@@ -26,6 +26,7 @@ import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
 import { resetSpawnerAttestationStore } from "@/features/agents/spawnerAttestationStore";
 import { resetSpawnerDirectoryStore } from "@/features/agents/spawnerDirectoryStore";
 import { resetSpawnerStatusStore } from "@/features/agents/spawnerStatusStore";
+import { resetTrustedSpawners } from "@/features/agents/trustedSpawners";
 import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
 import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync";
 import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
@@ -59,6 +60,11 @@ function resetCommunityState({
   // ask the user to sign with the wrong key. Status is community-scoped too.
   resetSpawnerAttestationStore();
   resetSpawnerStatusStore();
+  // So is the trust set that decides which prompts are skipped entirely. A
+  // spawner approved under the old identity must not silently auto-sign under
+  // the new one — that would admit its agent to a membership the user never
+  // vouched for. Re-approving after a switch is the safe direction to fail.
+  resetTrustedSpawners();
   // The spawner directory is community-scoped: announcements come from the
   // relay we just left.
   resetSpawnerDirectoryStore();

From 3c196c59d01141a694a453ed2282eb72c2dbbda0 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 01:54:46 +0530
Subject: [PATCH 07/16] fix(spawner): scope container and volume names per
 spawner identity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Labels are spawner-scoped but Docker names are global to the daemon, so
two spawners sharing a host collided on buzz-agent-<owner>-<slug> the
moment an owner moved an agent between them. The loser of the create race
called remove_name_squatter, which removed any agent-labelled container
regardless of which spawner owned it — force-removing a live agent to free
a name, whereupon the other spawner did the same on its next pass.

Names now carry the spawner prefix, so distinct identities cannot collide
at all, and reclaim additionally refuses any container whose com.buzz.spawner
label is not ours. What remains reclaimable is the case it was written for:
our own leftover from a crash between create and the store write.

Volume names get the same treatment — deleting an agent purges its volume,
and an unscoped name would wipe a co-tenant's running workspace.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 crates/buzz-spawner/src/container.rs | 42 +++++++++++++++++++---------
 crates/buzz-spawner/src/daemon.rs    | 36 +++++++++++++++++++-----
 crates/buzz-spawner/src/store.rs     | 37 ++++++++++++++++++++----
 3 files changed, 90 insertions(+), 25 deletions(-)

diff --git a/crates/buzz-spawner/src/container.rs b/crates/buzz-spawner/src/container.rs
index cb7adf5c68..89d70d2095 100644
--- a/crates/buzz-spawner/src/container.rs
+++ b/crates/buzz-spawner/src/container.rs
@@ -291,11 +291,12 @@ impl DockerOps {
         {
             Ok(created) => created,
             // The name can be squatted by a container a previous spawner
-            // identity created (names are owner+slug scoped, but list() only
-            // sees the current identity's label) — self-heal by removing it,
-            // but only after verifying the labels say it is an agent container.
+            // The name can be squatted by a container we created and then lost
+            // track of (a crash between create and the store write) — self-heal
+            // by removing it, but only after the labels confirm it is an agent
+            // container belonging to this spawner.
             Err(e) if is_name_conflict(&e) => {
-                self.remove_name_squatter(&spec.name)
+                self.remove_name_squatter(&spec.name, &spec.spawner_pubkey)
                     .await
                     .with_context(|| {
                         format!(
@@ -374,12 +375,21 @@ impl DockerOps {
 
     /// Remove an agent container squatting on `name`.
     ///
-    /// Only containers carrying the `com.buzz.agent` label are removed — that
-    /// label marks them as spawner-managed regardless of which spawner
-    /// identity created them, so orphans left by a retired identity are fair
-    /// game while an operator's unrelated container of the same name is not.
+    /// Two gates, both required. The container must carry the `com.buzz.agent`
+    /// label, so an operator's unrelated container of the same name is never
+    /// touched. And its `com.buzz.spawner` label must name *us* — a container
+    /// belonging to another spawner identity is left alone even though the name
+    /// blocks us, because that spawner may be alive and still serving it, and
+    /// force-removing a live agent to free a name is never the right trade.
+    ///
+    /// What that leaves is the case this exists for: a leftover from a crash
+    /// between `create` and the store write, under our own identity. Names
+    /// carry the spawner prefix (see [`AgentRecord::container_name`]), so a
+    /// conflict from any other identity means a genuine collision an operator
+    /// needs to see rather than something to silently resolve.
+    ///
     /// The workspace volume is a named mount and survives the removal.
-    async fn remove_name_squatter(&self, name: &str) -> Result<()> {
+    async fn remove_name_squatter(&self, name: &str, spawner_pubkey: &str) -> Result<()> {
         use bollard::query_parameters::InspectContainerOptions;
 
         let squatter = self
@@ -394,13 +404,19 @@ impl DockerOps {
                  refusing to remove a container the spawner does not manage"
             );
         }
-        let spawner = labels
-            .and_then(|labels| labels.get(SPAWNER_LABEL).cloned())
+        let owner = labels
+            .and_then(|labels| labels.get(SPAWNER_LABEL))
+            .map(String::as_str)
             .unwrap_or_default();
+        if owner != spawner_pubkey {
+            anyhow::bail!(
+                "conflicting container {name} belongs to spawner {owner}, not {spawner_pubkey}; \
+                 refusing to remove another spawner's agent to free the name"
+            );
+        }
         tracing::info!(
             container = %name,
-            previous_spawner = %spawner,
-            "removing orphaned agent container squatting on required name"
+            "removing our own orphaned agent container squatting on required name"
         );
         self.remove_inner(name, None).await
     }
diff --git a/crates/buzz-spawner/src/daemon.rs b/crates/buzz-spawner/src/daemon.rs
index 2f8d9c7f4b..48d3100959 100644
--- a/crates/buzz-spawner/src/daemon.rs
+++ b/crates/buzz-spawner/src/daemon.rs
@@ -392,7 +392,11 @@ impl Daemon {
                 slug,
                 container_id,
             } => {
-                let volume = volume_name(&owner_pubkey, &slug);
+                let volume = volume_name(
+                    &self.config.keys.public_key().to_hex(),
+                    &owner_pubkey,
+                    &slug,
+                );
                 if let Some(id) = container_id {
                     self.containers.remove(&id, Some(&volume)).await?;
                 }
@@ -551,7 +555,7 @@ impl Daemon {
         let (cpu_millis, memory_mib) = self.resources_for(&desired.spec);
 
         let spec = ContainerSpec {
-            name: record.container_name(),
+            name: record.container_name(&self.config.keys.public_key().to_hex()),
             image: self.config.agent_image.clone(),
             agent_pubkey: record.agent_pubkey.clone(),
             slug: record.slug.clone(),
@@ -569,7 +573,11 @@ impl Daemon {
             ),
             cpu_millis,
             memory_mib,
-            volume_name: volume_name(&desired.owner_pubkey, &desired.slug),
+            volume_name: volume_name(
+                &self.config.keys.public_key().to_hex(),
+                &desired.owner_pubkey,
+                &desired.slug,
+            ),
         };
 
         match self.containers.create(&spec).await {
@@ -818,9 +826,10 @@ struct PersonaContent {
 
 /// Per-agent volume name, scoped by owner for the same reason container names
 /// are: slugs are only unique per owner.
-fn volume_name(owner_pubkey: &str, slug: &str) -> String {
+fn volume_name(spawner_pubkey: &str, owner_pubkey: &str, slug: &str) -> String {
     format!(
-        "buzz-agent-{}-{}",
+        "buzz-agent-{}-{}-{}",
+        &spawner_pubkey[..12.min(spawner_pubkey.len())],
         &owner_pubkey[..12.min(owner_pubkey.len())],
         slug
     )
@@ -832,9 +841,22 @@ mod tests {
 
     #[test]
     fn volume_names_are_scoped_per_owner() {
+        let spawner = "s".repeat(64);
+        assert_ne!(
+            volume_name(&spawner, &"a".repeat(64), "fizz"),
+            volume_name(&spawner, &"b".repeat(64), "fizz")
+        );
+    }
+
+    #[test]
+    fn volume_names_are_scoped_per_spawner() {
+        // Two spawners on one host must not share a workspace volume: deleting
+        // an agent purges its volume, and an unscoped name would wipe the other
+        // spawner's still-running agent out from under it.
+        let owner = "a".repeat(64);
         assert_ne!(
-            volume_name(&"a".repeat(64), "fizz"),
-            volume_name(&"b".repeat(64), "fizz")
+            volume_name(&"s".repeat(64), &owner, "fizz"),
+            volume_name(&"t".repeat(64), &owner, "fizz")
         );
     }
 
diff --git a/crates/buzz-spawner/src/store.rs b/crates/buzz-spawner/src/store.rs
index 8a41cb0045..b511763220 100644
--- a/crates/buzz-spawner/src/store.rs
+++ b/crates/buzz-spawner/src/store.rs
@@ -66,20 +66,33 @@ impl AgentRecord {
         self.auth_tag.is_some() && !self.private_key_nsec.is_empty()
     }
 
-    /// The container name for this agent.
+    /// The container name for this agent, as created by `spawner_pubkey`.
     ///
     /// Keyed on the owner pubkey prefix as well as the slug: slugs are chosen by
     /// clients and are only unique *per owner*, so two owners both naming an
     /// agent `fizz` must not collide into one container on a shared host.
-    pub fn container_name(&self) -> String {
+    ///
+    /// Keyed on the *spawner* too, because labels are spawner-scoped but names
+    /// are global to the Docker daemon. Without it, two spawners sharing a host
+    /// collide the moment an owner moves an agent from one to the other: the
+    /// old spawner still manages the container while the new one needs the same
+    /// name, and reclaiming it would force-remove a live agent belonging to
+    /// somebody else — twice over, since the first spawner then does the same.
+    pub fn container_name(&self, spawner_pubkey: &str) -> String {
         format!(
-            "buzz-agent-{}-{}",
-            &self.owner_pubkey[..12.min(self.owner_pubkey.len())],
+            "buzz-agent-{}-{}-{}",
+            short_pubkey(spawner_pubkey),
+            short_pubkey(&self.owner_pubkey),
             self.slug
         )
     }
 }
 
+/// First 12 hex characters of a pubkey, for use in Docker object names.
+fn short_pubkey(pubkey: &str) -> &str {
+    &pubkey[..12.min(pubkey.len())]
+}
+
 /// The serialized state file.
 #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
 struct StateFile {
@@ -253,9 +266,23 @@ mod tests {
     fn container_names_are_scoped_per_owner() {
         // Slugs are client-chosen and only unique per owner. Two owners each
         // naming an agent "fizz" must not fight over one container.
+        let spawner = "s".repeat(64);
         let a = record(&"a".repeat(64), "fizz");
         let b = record(&"c".repeat(64), "fizz");
-        assert_ne!(a.container_name(), b.container_name());
+        assert_ne!(a.container_name(&spawner), b.container_name(&spawner));
+    }
+
+    #[test]
+    fn container_names_are_scoped_per_spawner() {
+        // The mutual-destruction case: an owner moves an agent from spawner A
+        // to spawner B on the same host. Unscoped names collide, and whichever
+        // spawner loses the race force-removes the other's live container to
+        // free the name — then the first does the same on its next pass.
+        let rec = record(&"a".repeat(64), "fizz");
+        assert_ne!(
+            rec.container_name(&"s".repeat(64)),
+            rec.container_name(&"t".repeat(64))
+        );
     }
 
     #[test]

From 34bfc8eff15d369ef1405b2757970d7d459bb6b7 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 01:57:05 +0530
Subject: [PATCH 08/16] fix(spawner): defer relay frames that interleave a
 persona query instead of dropping them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

fetch_personas holds the socket while it waits for EOSE, and discarded
anything that was not part of its own subscription. The comment justified
that with specs being replaceable and re-delivered on reconnect — true for
specs, but kind:24201 is ephemeral. An owner's attestation response that
arrived during a persona query was gone for good, leaving the agent in
pending_attestation until the handshake timed out and the owner was asked
to approve all over again.

Interleaved frames now go on a bounded queue that next() drains first.
The bound matters because anyone on the relay can address frames here;
eviction takes the oldest so a flood cannot displace a fresh frame, and a
reconnect clears the queue since those frames belong to the dead socket.

Also corrects the fetch_personas doc, which claimed the spawner reads
unshared personas through NIP-OA delegation. It does not: it authenticates
with no auth tag, so the delegation covers the agents it runs, never
itself. The daemon's own error message already said so.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 crates/buzz-spawner/src/relay.rs | 130 +++++++++++++++++++++++++++----
 1 file changed, 113 insertions(+), 17 deletions(-)

diff --git a/crates/buzz-spawner/src/relay.rs b/crates/buzz-spawner/src/relay.rs
index 39bd877629..8803ea3ec9 100644
--- a/crates/buzz-spawner/src/relay.rs
+++ b/crates/buzz-spawner/src/relay.rs
@@ -5,7 +5,10 @@
 //! frames sent to it, and publishes status back. It holds no database
 //! connection and no privileged relay access.
 
-use std::{collections::HashMap, time::Duration};
+use std::{
+    collections::{HashMap, VecDeque},
+    time::Duration,
+};
 
 use anyhow::{bail, Context, Result};
 use buzz_core::kind::{KIND_PERSONA, KIND_SPAWNER_AGENT_SPEC, KIND_SPAWNER_ATTESTATION};
@@ -33,6 +36,9 @@ const RECV_TIMEOUT: Duration = Duration::from_secs(5);
 /// How long to wait for a one-shot query to reach EOSE.
 const QUERY_TIMEOUT: Duration = Duration::from_secs(15);
 
+/// Cap on frames set aside while a one-shot query holds the socket.
+const MAX_DEFERRED_FRAMES: usize = 256;
+
 /// Something the daemon needs to act on.
 pub enum Inbound {
     /// A spec addressed to this spawner arrived or changed.
@@ -75,6 +81,9 @@ pub struct SpawnerRelay {
     conn: NostrWsConnection,
     keys: Keys,
     relay_url: String,
+    /// Frames received while a one-shot query held the socket, replayed by
+    /// [`Self::next`] before anything new is read.
+    deferred: VecDeque<RelayMessage>,
 }
 
 impl SpawnerRelay {
@@ -90,6 +99,7 @@ impl SpawnerRelay {
             conn,
             keys: keys.clone(),
             relay_url: relay_url.to_string(),
+            deferred: VecDeque::new(),
         };
         relay.subscribe_all().await?;
         Ok(relay)
@@ -97,6 +107,11 @@ impl SpawnerRelay {
 
     /// Reconnect after a transport failure, restoring both subscriptions.
     pub async fn reconnect(&mut self) -> Result<()> {
+        // Anything deferred belonged to the socket that just died. Specs are
+        // replayed by the new subscription, and an ephemeral frame held across
+        // a disconnect answers a handshake round the reconnect has already
+        // outlived, so acting on it would only apply stale state.
+        self.deferred.clear();
         self.conn = NostrWsConnection::connect_authenticated(&self.relay_url, &self.keys, None)
             .await
             .with_context(|| format!("failed to reconnect to {}", self.relay_url))?;
@@ -133,15 +148,25 @@ impl SpawnerRelay {
         Ok(())
     }
 
+    /// Set a frame aside for [`Self::next`], dropping the oldest under flood.
+    fn defer(&mut self, msg: RelayMessage) {
+        defer_frame(&mut self.deferred, msg);
+    }
+
     /// Wait for the next actionable relay frame.
     pub async fn next(&mut self) -> Result<Inbound> {
-        let msg = match self.conn.next_event(RECV_TIMEOUT).await {
-            Ok(msg) => msg,
-            // A timeout is the common case, not a failure: it just means no
-            // event arrived within the window, so the daemon can run its
-            // periodic reconcile and come back.
-            Err(buzz_ws_client::error::WsClientError::Timeout) => return Ok(Inbound::Idle),
-            Err(e) => return Err(e.into()),
+        // Frames set aside during a one-shot query come first, so an
+        // attestation response that raced a persona fetch is still acted on.
+        let msg = match self.deferred.pop_front() {
+            Some(msg) => msg,
+            None => match self.conn.next_event(RECV_TIMEOUT).await {
+                Ok(msg) => msg,
+                // A timeout is the common case, not a failure: it just means no
+                // event arrived within the window, so the daemon can run its
+                // periodic reconcile and come back.
+                Err(buzz_ws_client::error::WsClientError::Timeout) => return Ok(Inbound::Idle),
+                Err(e) => return Err(e.into()),
+            },
         };
 
         match msg {
@@ -306,10 +331,13 @@ impl SpawnerRelay {
 
     /// Fetch the personas authored by `owner`, keyed by `d` tag.
     ///
-    /// The spawner reads unshared personas through the NIP-OA delegation the
-    /// relay applies to attested readers, so an owner does not have to publish
-    /// their system prompts `["shared","true"]` to the whole community just to
-    /// run an agent.
+    /// Only reaches personas published `["shared","true"]`. Kind 30175 is
+    /// author-only otherwise, and the spawner authenticates as itself with no
+    /// NIP-OA tag (see [`Self::connect`]) — the owner delegation the relay
+    /// applies to attested readers covers the *agents* a spawner runs, never
+    /// the spawner. An unshared persona therefore has to arrive over the
+    /// encrypted attestation handshake instead; [`crate::daemon`] falls back to
+    /// that and says so when neither source has one.
     pub async fn fetch_personas(&mut self, owner: &PublicKey) -> Result<HashMap<String, Event>> {
         let sub_id = format!("personas-{}", &owner.to_hex()[..8]);
         self.conn
@@ -344,11 +372,17 @@ impl SpawnerRelay {
                 }) if subscription_id == sub_id => {
                     bail!("relay closed persona query: {message}");
                 }
-                // Frames for the standing subscriptions can interleave with
-                // this one-shot query. Dropping them is safe: specs are
-                // replaceable and re-delivered on reconnect, and the next
-                // reconcile pass re-reads state anyway.
-                Ok(_) => continue,
+                // Frames for the standing subscriptions interleave with this
+                // one-shot query. They cannot be dropped: kind:24201 is
+                // ephemeral, so an attestation response that arrives while a
+                // persona query is in flight is gone for good, and the agent it
+                // answers for sits in pending_attestation until the handshake
+                // times out and the owner is asked to approve all over again.
+                // Defer them to the next `next()` call instead.
+                Ok(other) => {
+                    self.defer(other);
+                    continue;
+                }
                 Err(buzz_ws_client::error::WsClientError::Timeout) => break,
                 Err(e) => return Err(e.into()),
             }
@@ -358,3 +392,65 @@ impl SpawnerRelay {
         Ok(personas)
     }
 }
+
+/// Push `msg` onto the deferral queue, evicting the oldest when full.
+///
+/// Bounded because anyone on the relay can address frames at this spawner; an
+/// unbounded queue would be a memory-growth lever for them. Eviction takes the
+/// oldest, so a flood cannot push out a frame that just arrived.
+fn defer_frame(deferred: &mut VecDeque<RelayMessage>, msg: RelayMessage) {
+    if deferred.len() >= MAX_DEFERRED_FRAMES {
+        deferred.pop_front();
+        warn!("deferred-frame queue is full; dropping the oldest frame");
+    }
+    deferred.push_back(msg);
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn eose(id: &str) -> RelayMessage {
+        RelayMessage::Eose {
+            subscription_id: id.to_string(),
+        }
+    }
+
+    fn id_of(msg: &RelayMessage) -> String {
+        match msg {
+            RelayMessage::Eose { subscription_id } => subscription_id.clone(),
+            _ => unreachable!("test only defers Eose frames"),
+        }
+    }
+
+    #[test]
+    fn deferred_frames_replay_in_arrival_order() {
+        // An attestation response that raced a persona query must still be
+        // acted on, and in the order the relay sent it.
+        let mut deferred = VecDeque::new();
+        defer_frame(&mut deferred, eose("first"));
+        defer_frame(&mut deferred, eose("second"));
+
+        assert_eq!(id_of(&deferred.pop_front().unwrap()), "first");
+        assert_eq!(id_of(&deferred.pop_front().unwrap()), "second");
+        assert!(deferred.is_empty());
+    }
+
+    #[test]
+    fn deferral_is_bounded_and_evicts_the_oldest() {
+        // Anyone can address frames at this spawner, so the queue must not be
+        // an unbounded allocation lever — and the newest frame, which is the
+        // one most likely to still matter, must survive the flood.
+        let mut deferred = VecDeque::new();
+        for i in 0..MAX_DEFERRED_FRAMES + 10 {
+            defer_frame(&mut deferred, eose(&i.to_string()));
+        }
+
+        assert_eq!(deferred.len(), MAX_DEFERRED_FRAMES);
+        assert_eq!(id_of(deferred.front().unwrap()), "10");
+        assert_eq!(
+            id_of(deferred.back().unwrap()),
+            (MAX_DEFERRED_FRAMES + 9).to_string()
+        );
+    }
+}

From e56f5706e1a23b04f997fe7d916521770a58e4a2 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 02:08:17 +0530
Subject: [PATCH 09/16] fix(desktop): retire a relocated agent before handing
 out its key
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The response to a relocation attestation contains the agent's secret key.
Marking the local copy relocated and stopping it was left to two follow-up
IPC calls from the renderer, after it had already published that response —
so any interruption in between (a crash, a navigation, a rejected call)
left the spawner running the identity while this device still considered
the agent local and startable. Both would then answer every mention, and
the owner would be billed twice per turn, silently.

Rust now does it atomically under the store lock before the signed event is
returned, and set_managed_agent_relocated stops any running process in the
same locked section rather than only blocking future starts. A failure
there aborts the hand-off, so the key never leaves.

That inverts the residual risk: a publish that fails now leaves the agent
stopped everywhere rather than running twice. The renderer rolls the flag
back when that happens, and says so loudly if even that fails — offline and
visible beats duplicated and silent.

Also corrects two docs: the module claimed signing 'fails closed' without a
recorded trust decision, which the backend never checked (the renderer owns
that boundary — now stated plainly), and the 'why this returns the event'
block had drifted onto the response struct, leaving the command undocumented.
Adds tests for the two branches that had none: that a rejection carries
neither key nor tag, and that a relocation delivers exactly the attested
identity's key.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 .../src-tauri/src/commands/agent_settings.rs  |  22 ++-
 desktop/src-tauri/src/commands/spawner.rs     | 130 +++++++++++++++---
 .../agents/spawnerAttestationStore.ts         |  67 +++++----
 3 files changed, 163 insertions(+), 56 deletions(-)

diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs
index 1fb580b32d..976d910131 100644
--- a/desktop/src-tauri/src/commands/agent_settings.rs
+++ b/desktop/src-tauri/src/commands/agent_settings.rs
@@ -5,8 +5,8 @@ use crate::{
     app_state::AppState,
     managed_agents::{
         build_managed_agent_summary, current_instance_id, find_managed_agent_mut,
-        load_managed_agents, load_personas, save_managed_agents, sync_managed_agent_processes,
-        ManagedAgentSummary,
+        load_managed_agents, load_personas, save_managed_agents, stop_managed_agent_process,
+        sync_managed_agent_processes, ManagedAgentSummary,
     },
     util::now_iso,
 };
@@ -73,9 +73,13 @@ pub async fn set_managed_agent_start_on_app_launch(
 /// relocation). Pass `Some(spawner_pubkey)` right after a successful
 /// attestation hand-off, `None` to move the agent back to this device.
 ///
-/// This is state, not an action: the flag is what every local start path —
+/// This is state *and* an action: the flag is what every local start path —
 /// manual start, app-launch restore, runtime reconcile, auto-restart — checks
-/// before spawning, so a relocated identity can never run in two places.
+/// before spawning, so a relocated identity can never be started again here.
+/// Relocating also stops any process already running for that key, in the same
+/// locked section: the flag alone only blocks *future* starts, and an agent
+/// mid-run would otherwise keep answering alongside its server copy until it
+/// happened to exit.
 #[tauri::command]
 pub async fn set_managed_agent_relocated(
     pubkey: String,
@@ -103,10 +107,20 @@ pub async fn set_managed_agent_relocated(
             state.clear_agent_session_caches(pubkey);
         }
 
+        let relocating = relocated_to_spawner.is_some();
         {
             let record = find_managed_agent_mut(&mut records, &pubkey)?;
             record.relocated_to_spawner = relocated_to_spawner;
             record.updated_at = now_iso();
+            if relocating {
+                // Stop before the flag is persisted rather than after, so a
+                // failure here aborts the whole hand-off instead of leaving a
+                // record that claims the agent moved while it is still running.
+                stop_managed_agent_process(&app, record, &mut runtimes)?;
+            }
+        }
+        if relocating {
+            state.clear_agent_session_caches(&pubkey);
         }
 
         save_managed_agents(&app, &records)?;
diff --git a/desktop/src-tauri/src/commands/spawner.rs b/desktop/src-tauri/src/commands/spawner.rs
index f44cd791bd..2c6172689b 100644
--- a/desktop/src-tauri/src/commands/spawner.rs
+++ b/desktop/src-tauri/src/commands/spawner.rs
@@ -11,14 +11,24 @@
 //! user's** membership. A malicious spawner that gets a signature can run an
 //! agent that reads the user's channels. So:
 //!
-//! - Signing is never automatic for an unrecognized spawner. The frontend must
-//!   have recorded an explicit trust decision first; [`respond_to_spawner_attestation`]
-//!   fails closed when it has not.
 //! - The tag is computed only for the agent pubkey named in the *decrypted*
 //!   frame, so a frame body cannot ask for a signature over some other key
 //!   while displaying a benign one.
+//! - The relocation key is looked up in this device's own store by that same
+//!   pubkey, so a spawner cannot name an arbitrary key and be handed it.
 //! - The response is NIP-44 encrypted back to the same spawner that asked, so
 //!   an eavesdropper on the ephemeral kind:24201 stream gets ciphertext.
+//!
+//! ## Where the trust decision lives
+//!
+//! The consent prompt and the remembered set of approved spawners are the
+//! renderer's (`features/agents/trustedSpawners.ts`), and the `trust` argument
+//! here is that answer passed back in. This module does **not** re-derive it:
+//! anything with IPC reach can therefore ask for a signature without a user
+//! ever seeing a prompt, and the renderer is the boundary being relied on. That
+//! is the same trust model as every other signing command in this app — the
+//! webview is not treated as hostile — but it is worth stating plainly rather
+//! than implying a backend check that does not exist.
 
 use nostr::JsonUtil;
 use serde::{Deserialize, Serialize};
@@ -26,6 +36,7 @@ use serde::{Deserialize, Serialize};
 use buzz_sdk_pkg::spawner::{build_spawner_attestation, AttestationFrame, PromptMaterial};
 
 use crate::app_state::AppState;
+use crate::commands::agent_settings::set_managed_agent_relocated;
 use crate::managed_agents::load_managed_agents;
 
 /// The trusted-spawner decision the frontend has already made.
@@ -89,6 +100,23 @@ pub async fn decode_spawner_attestation(
     }
 }
 
+/// Outcome of answering an attestation, for the caller to act on.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SpawnerAttestationResponse {
+    /// The signed kind:24201 event to publish over the WebSocket.
+    pub event_json: String,
+    /// Set when this hand-off relocated a locally-managed agent, meaning the
+    /// response carries that agent's secret key.
+    ///
+    /// The local copy is already marked relocated and stopped by the time this
+    /// returns. The caller needs the pubkey only to undo that — see
+    /// `set_managed_agent_relocated(pubkey, None)` — if publishing the event
+    /// fails and the spawner never receives the key.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub relocated_agent_pubkey: Option<String>,
+}
+
 /// Build the signed answer to an attestation request: sign the NIP-OA tag when
 /// `trust` is [`SpawnerTrust::Trusted`], and an explicit rejection otherwise.
 ///
@@ -104,21 +132,20 @@ pub async fn decode_spawner_attestation(
 /// `restricted: unknown event kind`. Kind 24200 observer control frames solve
 /// this the same way — `build_observer_control_event` signs here and the
 /// renderer publishes over the live socket.
-/// Outcome of answering an attestation, for the caller to act on.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct SpawnerAttestationResponse {
-    /// The signed kind:24201 event to publish over the WebSocket.
-    pub event_json: String,
-    /// Set when this handover relocated a locally-managed agent.
-    ///
-    /// The caller MUST stop the local process for this pubkey: two runners
-    /// holding one key both see every mention and both reply, so the agent
-    /// answers twice and burns two turns per message.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub relocated_agent_pubkey: Option<String>,
-}
-
+///
+/// # Why relocation is retired before the event is handed back
+///
+/// A relocation response contains the agent's secret key, so the instant it is
+/// published two runners hold one identity: both see every mention, both reply,
+/// and the owner is billed twice per turn. Marking and stopping the local copy
+/// here — not in a follow-up call from the renderer — means there is no window
+/// where that has happened and this device has not yet been told.
+///
+/// The residual risk is inverted rather than removed: if the publish then
+/// fails, the agent is stopped here and never started there. That is the better
+/// failure. It is visible (the agent is plainly stopped), it is recoverable by
+/// clearing the flag, and it is quiet — whereas a split identity is silent and
+/// costs money on every message until somebody notices.
 #[tauri::command]
 pub async fn respond_to_spawner_attestation(
     app: tauri::AppHandle,
@@ -214,6 +241,19 @@ pub async fn respond_to_spawner_attestation(
         .map_err(|e| format!("failed to build attestation event: {e}"))?
         .sign_with_keys(&keys)
         .map_err(|e| format!("failed to sign attestation event: {e}"))?;
+
+    // Retire the local copy before the caller can publish. If this fails the
+    // whole hand-off fails and the event is never returned, so the key stays on
+    // this device — the safe direction, since the alternative is handing out a
+    // key while the agent it belongs to keeps running here.
+    if let Some(relocated) = &relocated_agent_pubkey {
+        set_managed_agent_relocated(relocated.clone(), Some(spawner.to_hex()), app)
+            .await
+            .map_err(|e| {
+                format!("refusing to hand over the key: could not retire the local agent: {e}")
+            })?;
+    }
+
     Ok(SpawnerAttestationResponse {
         event_json: event.as_json(),
         relocated_agent_pubkey,
@@ -299,6 +339,60 @@ mod tests {
         assert!(buzz_sdk_pkg::nip_oa::verify_auth_tag(&auth_tag, &other.public_key()).is_err());
     }
 
+    #[test]
+    fn a_rejection_never_carries_a_key_or_a_tag() {
+        // The Untrusted arm is what runs when the user declines. It must not
+        // leak the agent's secret key, and it must not carry a signature that
+        // a spawner could treat as approval.
+        let frame = AttestationFrame::Reject {
+            spec_slug: "fizz-prod".into(),
+            agent_pubkey: Keys::generate().public_key().to_hex(),
+            nonce: "ab".repeat(ATTESTATION_NONCE_BYTES),
+            reason: Some("owner has not approved this spawner".into()),
+        };
+        let json = serde_json::to_string(&frame).unwrap();
+
+        assert!(!json.contains("private_key_nsec"));
+        assert!(!json.contains("auth_tag"));
+        assert!(!json.contains("nsec1"));
+    }
+
+    #[test]
+    fn a_relocation_response_carries_exactly_the_attested_identitys_key() {
+        // The spawner verifies this itself (evaluate_response rejects a key for
+        // a different agent), but the owner side must not build such a frame in
+        // the first place — the key is looked up by the pubkey in the decrypted
+        // request, so these two can never diverge.
+        let agent = Keys::generate();
+        let owner = Keys::generate();
+        let nsec = {
+            use nostr::nips::nip19::ToBech32;
+            agent.secret_key().to_bech32().unwrap()
+        };
+        let auth_tag =
+            buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap();
+
+        let frame = AttestationFrame::Response {
+            spec_slug: "fizz-prod".into(),
+            agent_pubkey: agent.public_key().to_hex(),
+            nonce: "ab".repeat(ATTESTATION_NONCE_BYTES),
+            auth_tag,
+            private_key_nsec: Some(nsec.clone()),
+            prompt: None,
+        };
+
+        let AttestationFrame::Response {
+            agent_pubkey,
+            private_key_nsec,
+            ..
+        } = &frame
+        else {
+            unreachable!("built a Response above")
+        };
+        let delivered = Keys::parse(private_key_nsec.as_ref().unwrap()).unwrap();
+        assert_eq!(&delivered.public_key().to_hex(), agent_pubkey);
+    }
+
     #[test]
     fn a_malformed_frame_is_refused_before_anything_is_signed() {
         let owner = Keys::generate();
diff --git a/desktop/src/features/agents/spawnerAttestationStore.ts b/desktop/src/features/agents/spawnerAttestationStore.ts
index 1c7ae42d81..e5a6442415 100644
--- a/desktop/src/features/agents/spawnerAttestationStore.ts
+++ b/desktop/src/features/agents/spawnerAttestationStore.ts
@@ -2,10 +2,7 @@ import React from "react";
 import { toast } from "sonner";
 
 import { getIdentity } from "@/shared/api/tauriIdentity";
-import {
-  setManagedAgentRelocated,
-  stopManagedAgent,
-} from "@/shared/api/tauriManagedAgents";
+import { setManagedAgentRelocated } from "@/shared/api/tauriManagedAgents";
 import {
   respondToSpawnerAttestation,
   subscribeToSpawnerAttestations,
@@ -83,45 +80,49 @@ function promptFor(specSlug: string): SpawnerPromptMaterial | undefined {
 }
 
 /**
- * Publish an attestation answer, then retire the local copy of a relocated
- * agent.
- *
- * The order is deliberate and load-bearing:
+ * Publish an attestation answer, undoing the relocation if it never lands.
  *
- * 1. **Publish first.** If the response never reaches the spawner the agent is
- *    still only running here, and stopping it first would take the user's agent
- *    offline everywhere for nothing.
- * 2. **Mark relocated second.** Relocation must be *state*, not just a stop:
- *    the auto-restart policy, app-launch restore, runtime reconcile, and the
- *    manual Start button all resurrect a merely-stopped agent (the split-brain
- *    found in testing came from exactly that). The persisted flag is what every
- *    start path checks, so it lands before the stop it protects.
- * 3. **Stop third, and only on `relocatedAgentPubkey`.** Rust sets that field
- *    exactly when it handed the agent's secret key to the spawner, so from that
- *    moment two processes hold one key. Both would see every mention and both
- *    would reply: duplicate answers, and the owner billed twice per turn.
+ * By the time the signed event exists, Rust has already marked the local agent
+ * relocated and stopped it — atomically, under the store lock, before handing
+ * the event back. It has to be that way round: the response *contains* the
+ * agent's secret key, so any gap between publishing it and retiring the local
+ * copy is a window where two runners hold one identity, both answer every
+ * mention, and the owner pays twice. A window this side of an IPC boundary
+ * cannot be closed by ordering two calls carefully, only by not having it.
  *
- * A failed mark or stop is surfaced as a toast rather than swallowed. It leaves
- * a real split brain that only the user can resolve, so silence is the one
- * unacceptable outcome — the publish itself already succeeded and must not be
- * retried.
+ * That leaves the opposite failure to handle here: the key was never actually
+ * delivered, and the agent is now stopped everywhere. Clearing the flag puts it
+ * back exactly where it was, so the user can retry.
  */
 async function publishAndRetireRelocated(
   response: SpawnerAttestationResponse,
-  spawnerPubkey: string,
 ): Promise<void> {
-  await respondToSpawnerAttestation(response.event);
-
   const relocated = response.relocatedAgentPubkey;
-  if (!relocated) return;
   try {
-    await setManagedAgentRelocated(relocated, spawnerPubkey);
-    await stopManagedAgent(relocated);
+    await respondToSpawnerAttestation(response.event);
+  } catch (error) {
+    if (relocated) {
+      await rollBackRelocation(relocated);
+    }
+    throw error;
+  }
+}
+
+/**
+ * Put a relocated agent back on this device after a failed publish.
+ *
+ * Best-effort by necessity — if this fails too, the agent is stopped and
+ * flagged as living on a server that never received its key, which no
+ * background retry can resolve. Say so loudly instead, and name the fix.
+ */
+async function rollBackRelocation(pubkey: string): Promise<void> {
+  try {
+    await setManagedAgentRelocated(pubkey, null);
   } catch (error) {
     toast.error(
-      `This agent now runs on the server, but the copy on this Mac could not be retired: ${
+      `The hand-off failed and this agent could not be moved back to this Mac: ${
         error instanceof Error ? error.message : "unknown error"
-      }. Stop it from the Agents screen — until you do, it will answer twice.`,
+      }. It is stopped everywhere until you move it back from the Agents screen.`,
       { duration: Number.POSITIVE_INFINITY },
     );
   }
@@ -183,7 +184,6 @@ async function handleAttestationEvent(event: RelayEvent): Promise<void> {
           trust: "trusted",
           prompt: promptFor(request.specSlug),
         }),
-        request.spawnerPubkey,
       );
     } catch {
       // Fall through to prompting. A failed auto-approval (relay down,
@@ -223,7 +223,6 @@ export async function approveAttestation(
       trust: "trusted",
       prompt: promptFor(item.specSlug),
     }),
-    item.spawnerPubkey,
   );
   // Remember only after the signature actually went out, so a failed publish
   // does not leave a spawner silently trusted for every future request.

From 4658b0a3a46b1d6e7badc4585d8fdc73ca7a1a33 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 02:11:32 +0530
Subject: [PATCH 10/16] docs(core): correct spawner kind docs; fix advertised
 capacity and state-file mode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Three small corrections found in review:

- kind:30179 claimed the relay accepts status only from the spec's
  designated spawner. There is no such gate — say so, and say why it is
  survivable (a status grants nothing; access comes from the owner's NIP-OA
  signature) so a client does not trust the kind alone.
- kind:30178 named NIP-09 deletion as the teardown signal, but a deletion
  leaves nothing to fan out and a connected spawner never learns of it. The
  implemented signal is an empty-content replacement.
- The announcement counted stopped containers as running, advertising a host
  as full when the agents filling it were disabled or crashed.
- The state file holding every agent's secret key was created under the
  process umask and chmod-ed afterwards. Open it 0600 instead, closing the
  window where it was briefly world-readable.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 crates/buzz-core/src/kind.rs      | 15 +++++++++++----
 crates/buzz-spawner/src/daemon.rs |  6 +++++-
 crates/buzz-spawner/src/store.rs  | 31 +++++++++++++++++++++++--------
 3 files changed, 39 insertions(+), 13 deletions(-)

diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs
index 47e5295523..3196471385 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -314,7 +314,9 @@ pub const KIND_SPAWNER_ANNOUNCEMENT: u32 = 10180;
 /// key, NIP-OA auth tag, env vars, or provider config. The system prompt is
 /// resolved through the referenced [`KIND_PERSONA`], never inlined here.
 ///
-/// Deleting the spec (NIP-09) is the signal to tear the agent down.
+/// Publishing an empty-content replacement is the signal to tear the agent
+/// down. A NIP-09 deletion does not work here: it leaves nothing to fan out, so
+/// a spawner that is already connected never learns the spec is gone.
 pub const KIND_SPAWNER_AGENT_SPEC: u32 = 30178;
 
 /// NIP-AS: Spawner Agent Status (parameterized replaceable, spawner-authored).
@@ -325,9 +327,14 @@ pub const KIND_SPAWNER_AGENT_SPEC: u32 = 30178;
 /// slug but differ in author.
 ///
 /// Content carries the reconciliation phase, the minted agent pubkey once it
-/// exists, and a human-readable error when the phase is failed. The relay
-/// accepts this kind only from the pubkey the referenced spec designates as its
-/// spawner — owners cannot forge status for their own specs.
+/// exists, and a human-readable error when the phase is failed.
+///
+/// The relay applies no author gate: anyone may publish this kind, and a client
+/// must therefore match a status to the spawner its own spec named rather than
+/// trusting the kind alone. Nothing is granted by a status event — access comes
+/// only from the owner's NIP-OA signature in a
+/// [`KIND_SPAWNER_ATTESTATION`] exchange — so a forged one misreports a badge
+/// and nothing more.
 pub const KIND_SPAWNER_AGENT_STATUS: u32 = 30179;
 
 // NIP-56 reporting
diff --git a/crates/buzz-spawner/src/daemon.rs b/crates/buzz-spawner/src/daemon.rs
index 48d3100959..f608482680 100644
--- a/crates/buzz-spawner/src/daemon.rs
+++ b/crates/buzz-spawner/src/daemon.rs
@@ -310,9 +310,13 @@ impl Daemon {
         }
 
         // Advertise after acting, so the published count reflects this pass.
+        // Running containers only: `list` reports stopped ones too, and counting
+        // those would advertise a host as full when the agents filling it are
+        // disabled or crashed, steering owners away from capacity that exists.
         // A failure here is not fatal: discovery is a convenience, and an
         // un-advertised spawner still works for anyone who knows its pubkey.
-        if let Err(e) = self.announce(containers.len()).await {
+        let running = containers.iter().filter(|c| c.running).count();
+        if let Err(e) = self.announce(running).await {
             warn!("failed to publish spawner announcement: {e:#}");
         }
         Ok(())
diff --git a/crates/buzz-spawner/src/store.rs b/crates/buzz-spawner/src/store.rs
index b511763220..37a0996f6e 100644
--- a/crates/buzz-spawner/src/store.rs
+++ b/crates/buzz-spawner/src/store.rs
@@ -189,24 +189,39 @@ impl Store {
         // Write-then-rename: a crash leaves either the old complete file or the
         // new complete file, never a truncated one holding half the agent keys.
         let tmp = self.path.with_extension("json.tmp");
-        std::fs::write(&tmp, &json)
-            .with_context(|| format!("failed to write {}", tmp.display()))?;
-        restrict_permissions(&tmp)?;
+        write_private(&tmp, &json).with_context(|| format!("failed to write {}", tmp.display()))?;
         std::fs::rename(&tmp, &self.path)
             .with_context(|| format!("failed to rename into {}", self.path.display()))?;
         Ok(())
     }
 }
 
+/// Create `path` mode 0600 and write `contents`.
+///
+/// The mode is set at open rather than chmod-ed afterwards: this file holds
+/// every agent's secret key, and creating it under the process umask would
+/// leave a window — however brief — where anything on the host could read it.
 #[cfg(unix)]
-fn restrict_permissions(path: &Path) -> Result<()> {
-    use std::os::unix::fs::PermissionsExt;
-    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
-        .with_context(|| format!("failed to chmod {}", path.display()))
+fn write_private(path: &Path, contents: &str) -> Result<()> {
+    use std::io::Write;
+    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
+
+    let mut file = std::fs::OpenOptions::new()
+        .write(true)
+        .create(true)
+        .truncate(true)
+        .mode(0o600)
+        .open(path)?;
+    file.write_all(contents.as_bytes())?;
+    // An existing temp file keeps its old mode, so set it explicitly too.
+    file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
+    file.sync_all()?;
+    Ok(())
 }
 
 #[cfg(not(unix))]
-fn restrict_permissions(_path: &Path) -> Result<()> {
+fn write_private(path: &Path, contents: &str) -> Result<()> {
+    std::fs::write(path, contents)?;
     Ok(())
 }
 

From 5784c1bd676a40dcf60d6eee9aad0eddf813d307 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 02:13:09 +0530
Subject: [PATCH 11/16] chore: drop scratch files from the spawner PR

ISSUE-buzz-spawner.md was the issue draft this work started from, and
run-dev.command is a personal launcher for running the GUI app from an
Aqua session. Neither is part of the feature. The launcher is gitignored
rather than deleted so an existing local copy keeps working.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 .gitignore            |   3 ++
 ISSUE-buzz-spawner.md | 107 ------------------------------------------
 run-dev.command       |   8 ----
 3 files changed, 3 insertions(+), 115 deletions(-)
 delete mode 100644 ISSUE-buzz-spawner.md
 delete mode 100755 run-dev.command

diff --git a/.gitignore b/.gitignore
index 65ddcaf1c4..9764c43b55 100644
--- a/.gitignore
+++ b/.gitignore
@@ -68,3 +68,6 @@ identity.key
 
 # Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build`
 deploy/charts/*/charts/*.tgz
+
+# Personal GUI-session launcher (see AGENTS.md); kept out of the repo.
+run-dev.command
diff --git a/ISSUE-buzz-spawner.md b/ISSUE-buzz-spawner.md
deleted file mode 100644
index 056c36e5ba..0000000000
--- a/ISSUE-buzz-spawner.md
+++ /dev/null
@@ -1,107 +0,0 @@
-# feat(spawner): first-party server-hosted agents via a buzz-spawner daemon
-
-## Problem
-
-Buzz agents can only be spawned by the desktop app.
-`desktop/src-tauri/src/managed_agents/runtime.rs::spawn_agent_child` generates
-the keypair, computes the NIP-OA owner attestation, and launches the `buzz-acp`
-harness locally. Fizz/Honey/Bumble aren't special processes — they're built-in
-personas (`managed_agents/personas.rs`) minted through that same local path.
-
-This means:
-
-- **Agents die with the laptop.** There is no always-on agent.
-- **Agent creation requires the desktop app.** Mobile and web can't create one.
-- **Self-hosters have no server-side option.** You can host a relay, but not an
-  agent. The only server-side path is `BackendKind::Provider` → an external
-  `buzz-backend-<id>` binary (`managed_agents/backend.rs:20-120`), which has no
-  OSS implementation and still puts the desktop in the control loop.
-- **The relay is entirely agent-agnostic** — no spawner, no agent registry, no
-  lifecycle hooks. Agents are just pubkeys admitted via NIP-OA owner attestation
-  (`crates/buzz-relay/src/api/mod.rs:40-200`).
-
-## Proposal
-
-Add `crates/buzz-spawner` — a standalone daemon that watches the relay for
-agent-definition events and reconciles them into Docker-isolated `buzz-acp`
-containers. It ships as a second service in `deploy/compose`.
-
-It is deliberately **not** a relay module: the relay's only subprocess today is
-`git`, and it goes out of its way to disable repo hooks
-(`api/git/transport.rs:930-935`). Adding container execution to the public
-WebSocket server isn't acceptable, and isn't necessary. `buzz-pair-relay` and
-`buzz-push-gateway` are existing precedent for sidecar service crates.
-
-The daemon is a plain relay client with its own Nostr identity. It never touches
-Postgres.
-
-```
-owner client ──kind:30178 (spec)──►  relay  ──sub──►  buzz-spawner
-     ▲                                                     │
-     └──kind:24201 attestation handshake (NIP-44)──────────┤
-                                                           ▼
-                                              Docker: buzz-acp container
-                                                           │
-                                                    (agent's own key)
-                                                           ▼
-                                                         relay
-```
-
-## Design points
-
-**Three new event kinds** (`buzz-core/src/kind.rs`), following the existing
-30175/30176/30177 agent block:
-
-| Kind | Purpose |
-|---|---|
-| `30178` `SPAWNER_AGENT_SPEC` | Desired state, owner-authored. `d` = spec slug |
-| `30179` `SPAWNER_AGENT_STATUS` | Actual state, spawner-authored. Phase, agent pubkey, last error |
-| `24201` `SPAWNER_ATTESTATION` | Ephemeral, NIP-44, `#p`-gated — the handshake |
-
-Specs follow the strict opt-IN projection discipline documented in
-`managed_agents/agent_events.rs` — the type must be physically incapable of
-carrying an nsec, auth tag, or env blob.
-
-**Attestation is a two-round handshake.** The nsec is minted on the VPS and never
-leaves it. But NIP-OA's tag is
-`Schnorr(SHA256("nostr:agent-auth:" || agent_pubkey || ":" || conditions), owner_secret)`
-(`buzz-sdk/src/nip_oa.rs`) — it binds a specific agent pubkey and needs the
-*owner's* secret key. So: owner publishes a spec → spawner mints keys and sends
-the new pubkey + nonce → owner's client signs via the existing `compute_auth_tag`
-and replies → spawner boots the container. Clients prompt on first handshake with
-a given spawner pubkey and auto-sign thereafter; never auto-sign for an unknown
-spawner.
-
-**One Docker container per agent.** Agents run shell and file-edit tools through
-`buzz-dev-mcp` — that's arbitrary code execution, so shared-host subprocesses
-aren't acceptable. Env is assembled exactly as `spawn_agent_child` does today,
-honoring the reserved-key strip list in `managed_agents/env_vars.rs:60`. LLM
-credentials come from spawner-level env and never appear in any event.
-
-**Personas resolve from `kind:30175`,** so a VPS Fizz is byte-identical to a local
-one. One access gap to close: `30175` is author-only unless `["shared","true"]`,
-and the spawner isn't the author — extend the read-gate using the same
-owner-delegation shape the relay already uses for `24200` observer frames
-(`ingest.rs:2029`), rather than inventing a second auth concept.
-
-**Reconciliation** mirrors the desktop's `reconcile.rs`/`runtime/sweep.rs`: diff
-specs against containers labelled `com.buzz.agent`, recreate on spec-hash drift
-(reusing the `spawn_hash` idea), exponential backoff on crash loops with a cap,
-and report failures via `30179` instead of retrying silently.
-
-## Scope
-
-- New: `crates/buzz-spawner/`, `docker/buzz-acp.Dockerfile`
-- Modified: `buzz-core/src/kind.rs`, `buzz-relay` ingest gates + persona
-  read-gate, `buzz-sdk/src/builders.rs`, `buzz-cli`
-  (`spawner list|logs|restart`), desktop attestation responder,
-  `deploy/compose/*`
-- Requires `BUZZ_ALLOW_NIP_OA_AUTH=true` (currently defaults false,
-  `buzz-relay/src/config.rs:172-184`)
-
-## Open item for reviewers
-
-Mounting `/var/run/docker.sock` into the spawner is root-equivalent on the host.
-This needs to be documented loudly in `deploy/compose/README.md`, and it's worth
-discussing whether a rootless-Docker or Podman-socket variant should be the
-default recommendation.
diff --git a/run-dev.command b/run-dev.command
deleted file mode 100755
index 2c9a03ee29..0000000000
--- a/run-dev.command
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/zsh
-# Launches Buzz dev in the Aqua (GUI) session so the app gets keyboard focus.
-# Claude Code / tmux shells run in a Background launchd session — apps launched
-# there can never become the active app. See memory: gui-apps-need-aqua-session.
-cd "$(dirname "$0")"
-echo "session: $(launchctl managername)"   # should print Aqua
-. ./bin/activate-hermit
-exec just dev

From c14bf88c5e66785422167b927dcd35fe329a3a24 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 02:16:58 +0530
Subject: [PATCH 12/16] fix(desktop): unwedge spawner subscriptions, guard slug
 collisions, honour deploy failures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Three lower-severity findings from review:

- Both spawner subscription stores kept their in-flight promise when the
  attempt opened no subscription — no identity yet during onboarding, or a
  throw from getIdentity. Every later ensure...() then returned that settled
  promise and reported success, so attestation prompts and status updates
  never arrived again until the app restarted. Clear it unconditionally; the
  unsubscribeRelay check is what keeps the call idempotent.

- create() and relocate() published a kind:30178 spec without checking
  whether the slug was already in use on that spawner. The kind is addressed
  by (author, kind, d_tag), so a second publish replaces the first rather
  than failing: relocating a local "Fizz" silently reconfigured a running
  server agent of the same name, and tore it down when the identities
  differed. Both now refuse up front. DeployMenu already filtered this case,
  so its behaviour is unchanged.

- Both callers of deployToSpawner ignored its boolean result and reported
  success regardless, closing the dialog on a failed publish and leaving a
  transient toast as the only evidence.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 .../agents/spawnerAttestationStore.ts         |  8 +++++-
 .../src/features/agents/spawnerStatusStore.ts |  6 ++++-
 .../features/agents/ui/usePersonaActions.ts   | 10 +++++++-
 .../src/features/agents/useAgentManagement.ts | 10 +++++++-
 .../src/features/agents/useServerAgents.ts    | 25 +++++++++++++++++--
 5 files changed, 53 insertions(+), 6 deletions(-)

diff --git a/desktop/src/features/agents/spawnerAttestationStore.ts b/desktop/src/features/agents/spawnerAttestationStore.ts
index e5a6442415..349af2adfc 100644
--- a/desktop/src/features/agents/spawnerAttestationStore.ts
+++ b/desktop/src/features/agents/spawnerAttestationStore.ts
@@ -279,7 +279,13 @@ export async function ensureSpawnerAttestationSubscription(): Promise<void> {
   try {
     await startPromise;
   } finally {
-    if (unsubscribeRelay) startPromise = null;
+    // Cleared unconditionally. Keeping the settled promise around when no
+    // subscription was opened — no identity yet during onboarding, or a throw
+    // from getIdentity — made every later call return that same finished
+    // promise and report success, so attestation prompts never arrived again
+    // until the app restarted. A later call re-enters instead; the
+    // `unsubscribeRelay` check above is what keeps this idempotent.
+    startPromise = null;
   }
 }
 
diff --git a/desktop/src/features/agents/spawnerStatusStore.ts b/desktop/src/features/agents/spawnerStatusStore.ts
index ce8a08cb96..1a33d475cd 100644
--- a/desktop/src/features/agents/spawnerStatusStore.ts
+++ b/desktop/src/features/agents/spawnerStatusStore.ts
@@ -103,7 +103,11 @@ export async function ensureSpawnerStatusSubscription(): Promise<void> {
   try {
     await startPromise;
   } finally {
-    if (unsubscribeRelay) startPromise = null;
+    // Cleared unconditionally — see the same guard in
+    // `spawnerAttestationStore`. Retaining a settled promise from an attempt
+    // that opened no subscription (no identity yet, or a throw) wedged this
+    // store shut for the rest of the session.
+    startPromise = null;
   }
 }
 
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts
index 19e980201c..d09b93f129 100644
--- a/desktop/src/features/agents/ui/usePersonaActions.ts
+++ b/desktop/src/features/agents/ui/usePersonaActions.ts
@@ -247,7 +247,15 @@ export function usePersonaActions() {
         // — the spawner resolves the prompt from it.
         const runLocation = location ?? LOCAL;
         if (runLocation.kind === "spawner") {
-          await deployToSpawner(persona, runLocation.spawnerPubkey);
+          // The publish can fail (relay down, slug already taken). The hook has
+          // already explained it in a toast, but the dialog must stay open —
+          // closing it reads as "deployed", and the only trace of the failure
+          // would be a toast that disappears.
+          const deployed = await deployToSpawner(
+            persona,
+            runLocation.spawnerPubkey,
+          );
+          if (!deployed) return false;
           setPersonaDialogState(null);
           return true;
         }
diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts
index 0d345990ac..d4935ff933 100644
--- a/desktop/src/features/agents/useAgentManagement.ts
+++ b/desktop/src/features/agents/useAgentManagement.ts
@@ -212,7 +212,15 @@ export function useAgentManagement() {
       if (runLocation.kind === "spawner") {
         // The spawner owns the process and mints the agent's key, so there is
         // no local instance to create and no pubkey to add to the channel yet.
-        await deployToSpawner(persona, runLocation.spawnerPubkey);
+        // A failed publish must not read as success: the hook has already
+        // surfaced the reason, so stop here rather than reporting a deployment
+        // that never happened.
+        if (!(await deployToSpawner(persona, runLocation.spawnerPubkey))) {
+          // The definition was created and is kept — only the deploy failed,
+          // so the user can retry from the Agents screen without re-entering
+          // everything. Returning false keeps the dialog open to say so.
+          return false;
+        }
       } else if (intent === "definition_start") {
         const created = await createAgentMutation.mutateAsync(
           await buildInstanceInputForDefinition(
diff --git a/desktop/src/features/agents/useServerAgents.ts b/desktop/src/features/agents/useServerAgents.ts
index 338e07013a..8062fb0aa4 100644
--- a/desktop/src/features/agents/useServerAgents.ts
+++ b/desktop/src/features/agents/useServerAgents.ts
@@ -72,6 +72,25 @@ export function useServerAgents() {
       );
   }, [spawners, statuses]);
 
+  /**
+   * Refuse a slug already in use on this spawner.
+   *
+   * kind:30178 is addressed by `(author, kind, d_tag)`, so publishing a second
+   * spec under the same slug does not fail — it *replaces* the first. Whatever
+   * agent was running there is reconfigured out from under itself, and if the
+   * new spec names a different identity the spawner tears the old one down.
+   */
+  const assertSlugFree = React.useCallback(
+    (spawnerPubkey: string, slug: string) => {
+      if (statuses.has(spawnerStatusKey(spawnerPubkey, slug))) {
+        throw new Error(
+          `An agent named "${slug}" already runs on this spawner. Remove it first, or rename this one.`,
+        );
+      }
+    },
+    [statuses],
+  );
+
   /**
    * Create a server agent from a persona.
    *
@@ -95,6 +114,7 @@ export function useServerAgents() {
           "Could not derive a name for this agent. Rename it using letters or digits.",
         );
       }
+      assertSlugFree(spawnerPubkey, slug);
       setIsPending(true);
       try {
         await publishSpawnerAgentSpec({
@@ -113,7 +133,7 @@ export function useServerAgents() {
         setIsPending(false);
       }
     },
-    [],
+    [assertSlugFree],
   );
 
   /**
@@ -129,6 +149,7 @@ export function useServerAgents() {
     async (agent: RelocatableAgent, spawnerPubkey: string) => {
       if (!spawnerPubkey) throw new Error("No spawner selected.");
       const { slug, spec } = buildRelocationPlan(agent);
+      assertSlugFree(spawnerPubkey, slug);
       setIsPending(true);
       try {
         await publishSpawnerAgentSpec({ slug, spawnerPubkey, spec });
@@ -137,7 +158,7 @@ export function useServerAgents() {
         setIsPending(false);
       }
     },
-    [],
+    [assertSlugFree],
   );
 
   /**

From ccb1a5a40a5d81bfd6eb3433327620c8ba33f4a4 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 13:09:31 +0530
Subject: [PATCH 13/16] fix(spawner): install the rustls CryptoProvider before
 connecting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The spawner panicked inside rustls on its first wss:// connection and
crash-looped at startup, never reaching its own error handling. The
workspace pulls in both ring and aws-lc-rs through unified features, so
rustls refuses to guess which provider to use and the process must install
one explicitly — as buzz-cli, buzz-acp, buzz-admin, and buzz-dev-mcp all
already do. The spawner was the only wss:// client that did not.

Found by deploying against a TLS relay. A ws:// relay is unaffected, which
is why local development and the test suite never hit it.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 Cargo.lock                      |  1 +
 crates/buzz-spawner/Cargo.toml  |  6 ++++++
 crates/buzz-spawner/src/main.rs | 13 +++++++++++++
 3 files changed, 20 insertions(+)

diff --git a/Cargo.lock b/Cargo.lock
index ea70a56f15..7a649d92d4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1292,6 +1292,7 @@ dependencies = [
  "hex",
  "nostr",
  "rand 0.10.1",
+ "rustls",
  "serde",
  "serde_json",
  "sha2 0.11.0",
diff --git a/crates/buzz-spawner/Cargo.toml b/crates/buzz-spawner/Cargo.toml
index 7c8ac044a6..99ea906f78 100644
--- a/crates/buzz-spawner/Cargo.toml
+++ b/crates/buzz-spawner/Cargo.toml
@@ -31,6 +31,12 @@ futures-util = { workspace = true }
 chrono = { workspace = true }
 async-trait = "0.1"
 bollard = "0.21"
+# Explicit rustls dep with the ring provider, so the binary can install a
+# process-level CryptoProvider. The workspace unifies both ring and aws-lc-rs
+# features, leaving rustls unable to auto-select one; without an explicit
+# install every wss:// relay connection panics. Same rationale as buzz-cli,
+# buzz-acp, and buzz-admin.
+rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
 
 [dev-dependencies]
 tokio = { workspace = true, features = ["test-util"] }
diff --git a/crates/buzz-spawner/src/main.rs b/crates/buzz-spawner/src/main.rs
index 962222dd93..64287f290a 100644
--- a/crates/buzz-spawner/src/main.rs
+++ b/crates/buzz-spawner/src/main.rs
@@ -19,6 +19,19 @@ async fn main() -> Result<()> {
         )
         .init();
 
+    // Install ring as the process-level rustls CryptoProvider, matching
+    // buzz-cli, buzz-acp, buzz-admin, and buzz-dev-mcp.
+    //
+    // Both ring and aws-lc-rs end up enabled through the workspace's unified
+    // feature set, and with two providers compiled in rustls refuses to guess.
+    // Without this the very first `wss://` connection panics inside rustls
+    // rather than returning an error — so a spawner pointed at a TLS relay
+    // crash-loops at startup and never reaches its own error handling. A `ws://`
+    // relay is unaffected, which is why local development did not catch it.
+    //
+    // `let _ =` is deliberate: a redundant install returns Err and is harmless.
+    let _ = rustls::crypto::ring::default_provider().install_default();
+
     let config = Config::from_env()?;
     let containers: Arc<dyn ContainerOps> = Arc::new(DockerOps::connect()?);
 

From 30981fe111262d2cf3f49b980708b98b10248ade Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Tue, 28 Jul 2026 14:42:28 +0530
Subject: [PATCH 14/16] fix(deploy): pass the spawner's name and host defaults
 through compose
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

compose.spawner.yml never forwarded BUZZ_SPAWNER_NAME, so Config::default_name
fell back to $HOSTNAME — which inside a container is its short ID. Every
containerised spawner therefore advertised something like "8c6b9debbaf9" in
the desktop's picker, and setting the documented variable had no effect at
all. BUZZ_SPAWNER_DESCRIPTION and the DEFAULT_PROVIDER/DEFAULT_MODEL host
fallbacks were missing for the same reason.

All four default to empty, and the config layer treats empty as absent, so
this only ever improves on the previous behaviour.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 deploy/compose/compose.spawner.yml | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/deploy/compose/compose.spawner.yml b/deploy/compose/compose.spawner.yml
index 751737d1b3..07d6d6ec8f 100644
--- a/deploy/compose/compose.spawner.yml
+++ b/deploy/compose/compose.spawner.yml
@@ -46,6 +46,19 @@ services:
       BUZZ_SPAWNER_MAX_AGENTS: ${BUZZ_SPAWNER_MAX_AGENTS:-16}
       BUZZ_SPAWNER_MAX_CPU_MILLIS: ${BUZZ_SPAWNER_MAX_CPU_MILLIS:-4000}
       BUZZ_SPAWNER_MAX_MEMORY_MIB: ${BUZZ_SPAWNER_MAX_MEMORY_MIB:-8192}
+      # How this spawner names itself in the desktop's picker. Without these,
+      # `Config::default_name` falls back to $HOSTNAME — which inside a
+      # container is its short ID, so the picker offers something like
+      # "8c6b9debbaf9" and the operator has no way to say otherwise. Left unset
+      # the fallback still applies, so this only ever improves on the default.
+      BUZZ_SPAWNER_NAME: ${BUZZ_SPAWNER_NAME:-}
+      BUZZ_SPAWNER_DESCRIPTION: ${BUZZ_SPAWNER_DESCRIPTION:-}
+      # Provider/model used when a spec names neither. The buzz-agent harness
+      # refuses to start without a provider, and a spec is allowed to omit it —
+      # a spawner has no global settings to fall back on, so the operator
+      # supplies the host default here.
+      BUZZ_SPAWNER_DEFAULT_PROVIDER: ${BUZZ_SPAWNER_DEFAULT_PROVIDER:-}
+      BUZZ_SPAWNER_DEFAULT_MODEL: ${BUZZ_SPAWNER_DEFAULT_MODEL:-}
       # Names of variables to forward into agent containers — not KEY=VALUE
       # pairs. The value is read from this service's own environment, so a
       # rotated credential is picked up by restarting the spawner.

From fcf55658a93d349162aa28d4a9b2b8f8891846b1 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Thu, 30 Jul 2026 15:42:44 +0530
Subject: [PATCH 15/16] fix: add relocation field to snapshot fidelity test
 fixture

Rebase integration: main added personas/snapshot/fidelity_tests.rs while
this branch added ManagedAgentRecord::relocated_to_spawner.

Signed-off-by: sid <siddhartpai@gmail.com>
(cherry picked from commit 16449c30a2b101236da980a8fded9ce36125c704)
Signed-off-by: sid <siddhartpai@gmail.com>
---
 .../src-tauri/src/commands/personas/snapshot/fidelity_tests.rs   | 1 +
 1 file changed, 1 insertion(+)

diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
index 00a1457393..17f952be5b 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
@@ -39,6 +39,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
         runtime_pid: None,
         backend: BackendKind::Local,
         backend_agent_id: None,
+        relocated_to_spawner: None,
         provider_binary_path: None,
         team_id: None,
         persona_team_dir: None,

From 560a3eb5dee105b2083c30e3e878cf0cced81f55 Mon Sep 17 00:00:00 2001
From: sid <siddhartpai@gmail.com>
Date: Thu, 30 Jul 2026 18:07:24 +0530
Subject: [PATCH 16/16] fix: pass targetChannel slot when forwarding definition
 submit options

Rebase integration: usePersonaActions.handleSubmit gained a targetChannel
parameter upstream, so the options argument was landing in the wrong slot.

Signed-off-by: sid <siddhartpai@gmail.com>
---
 desktop/src/features/agents/ui/AgentsView.tsx | 1 +
 1 file changed, 1 insertion(+)

diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx
index d2b04095ac..599642f9c9 100644
--- a/desktop/src/features/agents/ui/AgentsView.tsx
+++ b/desktop/src/features/agents/ui/AgentsView.tsx
@@ -321,6 +321,7 @@ export function AgentsView() {
               undefined,
               undefined,
               undefined,
+              undefined,
               options,
             )
           }
