Skip to content

docs: self-hosted backend guide and deploy notes (4/4) - #3188

Draft
siddhartpai wants to merge 54 commits into
block:mainfrom
siddhartpai:server-agent-docs-deploy
Draft

docs: self-hosted backend guide and deploy notes (4/4)#3188
siddhartpai wants to merge 54 commits into
block:mainfrom
siddhartpai:server-agent-docs-deploy

Conversation

@siddhartpai

@siddhartpai siddhartpai commented Jul 27, 2026

Copy link
Copy Markdown

Part 4 of the 4-PR server-agent stack — stacked on #3187; shrinks to its own 4 commits as predecessors merge. Own diff: 10 files, +1,874/−14 (mostly a plan doc).

Docs & deploy tail: the self-hosted backend guide (relay + spawner) linked from the README, the per-owner credential BREAKING note in deploy/compose/compose.spawner.yml, and the per-owner-credentials plan doc (context only — skim). Near-zero review effort.

🤖 Generated with Claude Code

siddhartpai and others added 3 commits July 28, 2026 00:52
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>
…in fixtures)

Signed-off-by: sid <siddhartpai@gmail.com>
…wner identity

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>
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>
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>
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>
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>
…ead of dropping them

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>
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>
…ate-file mode

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>
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>
…onour deploy failures

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>
@siddhartpai
siddhartpai force-pushed the server-agent-docs-deploy branch from 648942a to 719e077 Compare July 28, 2026 04:45
siddhartpai added a commit to siddhartpai/buzz that referenced this pull request Jul 28, 2026
Brings in the full 4-PR stack still under review upstream as block#3185-block#3188:

- buzz-spawner daemon: reconciles owner-published kind:30178 specs into
  per-agent Docker containers, with a two-round NIP-OA attestation handshake
- desktop: server-agent UI, attestation consent flow, identity relocation
- first-party prompt/model editing for server-hosted agents
- per-owner Claude credentials, replacing host-global API key passthrough
- self-hosted backend guide and deploy notes

Merged on the fork for building and testing; the upstream PRs remain open.
Includes 18 upstream commits that landed after the stack was branched — all
13 overlapping files auto-merged cleanly.

Signed-off-by: sid <siddhartpai@gmail.com>
siddhartpai and others added 14 commits July 28, 2026 14:48
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>
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>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
…al::hash

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
…ames

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
…date send path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
siddhartpai and others added 12 commits July 28, 2026 14:50
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
…size guard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
A desktop prompt update carries only system prompt, model, and provider, so
replacing the stored material wholesale wiped the agent's team instructions on
the next container restart. Carry them forward in a new record field instead of
merging them into the stored prompt: that field must stay byte-identical to the
frame the owner sent, since its hash is what the status event echoes and what
the client matches to confirm the update landed.

Also warn on `PromptMaterial::hash` that the hash is published world-readable,
so no credential field may ever join the struct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
The queue read storage at module import, when the cached relay origin is still
null, so it loaded from a bare key while every write went to the origin-scoped
one — pending updates never survived a restart. Hydrate lazily on first access
instead, and make the community reset clear only in-memory state: persisting an
empty map under the departing relay's key deleted that community's pending
edits.

Ack by the agent pubkey the spawner reports, with the slug only as a fallback
for a status that has none. The slug a client queues under can drift from the
spawner's (name-derived fallback, or a rename), and matching on it alone meant
an ack that never arrived — a resend and a container restart every three
minutes, forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
An all-blank edit was still queued, but the spawner drops empty material
without storing or acking it, so the entry stayed pending and was resent
forever. Skip it at the push site.

The pending chip also claimed the server was offline during the normal
awaiting-ack window; it now says that only when the send itself failed, and it
renders for any agent row with a pending update rather than relocated ones
alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
…t a resolved relay origin

The community reset nulls the cached relay origin right after resetting this
queue, and it only refreshes asynchronously — so any access in that window
hydrated from the shared empty-origin key and latched hydration for the whole
session. The new community's persisted entries never loaded, and the next write
pushed that wrong-key map onto the real one.

Skip hydration (without latching) and skip persisting while the origin is
unknown, merge rather than replace when it finally resolves so an edit queued
in the meantime survives, and hydrate on the origin subscription instead of
waiting for the next access.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
… agents

A spawner-hosted agent authenticates on the server (e.g. OAuth), but both
edit dialogs still ran local model discovery and gated Save on local
provider credentials, so editing such an agent demanded an ANTHROPIC_API_KEY
this machine never uses and surfaced a spurious "Enter an Anthropic API key"
warning.

- usePersonaModelDiscovery: new serverManaged flag skips local discovery
  and the "Runtime not available" warning for server-hosted agents; the
  model catalog already comes from the spawner announcement.
- localModeGateSatisfiedForSubmit: definition-dialog Save waives local
  credential env keys when server-managed (provider/model still required).
- computeEditAgentFormValidity: instance-dialog Save ignores
  requiredEnvKeyMissing when server-managed.

Mirrors the existing Databricks OAuth precedent of not requiring a typed
credential when auth happens elsewhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
… server-aware dialog

Signed-off-by: sid <siddhartpai@gmail.com>
@siddhartpai
siddhartpai force-pushed the server-agent-docs-deploy branch from 719e077 to 3b3a011 Compare July 28, 2026 09:22
siddhartpai and others added 16 commits July 28, 2026 16:36
BUZZ_SPAWNER_AI_CATALOG was documented in .env.example but never listed in
compose.spawner.yml's environment block, so the variable never reached the
container. Config::from_env therefore always saw it absent, the announcement
carried no `ai` catalog, and the desktop's model field degraded to free text
with "Model list unavailable from this server".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
(cherry picked from commit a1297eb)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
…ial status

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
… host-global keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
… updates with ack and restart

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
…ential badge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Spawners announce themselves; the manual pubkey entry card and its
screenshot coverage are no longer needed.

Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
…e build

Lead with the one rule that explains most failures (every client must use
the same public hostname, since the relay derives the tenant from the Host
header), document the silent cross-host spawner failure mode, explain
BUZZ_SPAWNER_AGENT_RELAY_URL constraints inside containers, and show how
to build a local buzz-acp image instead of pulling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
@siddhartpai
siddhartpai force-pushed the server-agent-docs-deploy branch from 3b3a011 to 9854ad1 Compare July 28, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant