Skip to content

feat(agent-host): run a saved agent without the desktop app - #967

Draft
loganj wants to merge 17 commits into
mainfrom
agent-host-standalone
Draft

loganj wants to merge 17 commits into
mainfrom
agent-host-standalone

Conversation

@loganj

@loganj loganj commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Third of three PRs that let a Buzz agent run without the desktop app open (see Why).
  • Adds buzz-agent-host, a Node program. buzz-agent-host run <pubkey> runs one Claude or Codex agent that the app saved on this computer. It uses the same run code as the app (refactor(agents): separate the agent runner and its reads from the desktop app #961) and the same saved state (feat(agents): keep agent state in files that a program outside the app can read #974): the agent's settings, watches, timers, team instructions and Claude or Codex sessions. So the agent answers mentions, follows its watches, runs its timers and continues its conversations the same way in both places.
  • The agent's key never enters Node. The program starts the app's own executable in a new headless mode, <app> agent-host <pubkey>, and asks it to sign each request. That mode runs the same Rust code the app's window uses for its agents: identities, Keychain, hosting locks, storage and the allowed event kinds.
  • Only one copy runs an agent at a time. While the installed app runs the agent, the program waits. When the app quits, the program takes over the agent at its next check.

Why

Buzz agents are AI assistants, such as Claude or Codex, that a person sets up in the Buzz desktop app. They answer when someone mentions them in a channel, and they can also watch for events and run on a timer. Today an agent runs only while its owner's desktop app is open, because the code that runs agents lives inside the app.

The goal of this stack is a small command-line program that runs an agent with the app closed. It must use the same code and the same saved state as the app, so the agent behaves the same wherever it runs. The app keeps working exactly as it does today.

Stack

#961 and #974 are merged. Each PR keeps the app working.

  1. refactor(agents): separate the agent runner and its reads from the desktop app #961: move the code that decides when an agent runs into its own module, and let the Claude and Codex agent code read conversations without depending on the app.
  2. feat(agents): keep agent state in files that a program outside the app can read #974: keep agent state in files that a program outside the app can read.
  3. feat(agent-host): run a saved agent without the desktop app #967 (this PR): add buzz-agent-host, the program that runs an agent with the app closed.

How to try it

pnpm agent-host
BUZZ_APP=<path to Buzz.app or its executable> node dist-agent-host/buzz-agent-host.mjs run <pubkey>
  • The agent must already be saved by the app on this computer.
  • BUZZ_APP names the app to use: a Buzz.app bundle, or an executable such as a dev build's src-tauri/target/debug/buzz-foundation. Without it, the program uses /Applications/Buzz.app. A bundle must have the identifier dev.local.buzz.foundation, so another app named Buzz is refused.
  • macOS may ask once to allow the app to read the agent's Keychain item.

Details

  • Custody (src-tauri/src/agent_host.rs, src/agent-host/custody.ts): main.rs checks for agent-host before Tauri starts. That mode reads one JSON request per line on stdin and writes one answer per line on stdout. It answers the same commands the app's window uses (app_agent_claim, _publish, _query, _upload, _media, _git_auth, _remember, _state_* and the others), through the same functions: each Tauri command is now a thin wrapper around a function that takes &AppAgentHost. It serves only its own agent, except for the agent list (for team names) and the shared state files. Other commands, such as create or delete, are refused. On the Node side, agentsNative and the state storage now take an invoke, so the program uses the app's own TypeScript wrappers with the helper's call.
  • Three new commands, for things the app does as the owner and the program must do as the agent: app_agent_login signs the relay login (kind 22242) for the agent's own community only. app_agent_read_auth gives the NIP-98 header for the agent's /query and /profiles/search reads only. app_agent_memories reads the agent's memory with its own key. The NIP-44 key between the agent and the owner is the same from both sides, so the decoder now picks the other side from the key it is given.
  • Locks: the helper takes the app's hosting locks with the app's code (Hosting::claim), as a dev build does, so it steps aside for the installed app. It exits when the program closes its stdin, and the OS then releases its locks, also after a crash. If the helper stops on its own, the program stops, because the agent is no longer held.
  • Connection: the program connects through the app's own relay session (createRelaySession, the one the desktop's owner session uses), signed in as the agent with the owner's attestation (src/agent-host/session.ts). That session keeps the agent's channels, rosters and Solo labels, hears them live and recovers as the app does. The agent's reads are the app's ownerReads on that session. The session cannot publish: it gets no writer, and its socket sends only the login and subscriptions (a test checks this).
  • Publishing: through the helper, which signs only the event kinds the app allows for agents (app_agent.rs).
  • Git access to channel repositories now works, through the app's app_agent_git_auth.
  • Shared with the app, not copied: the roster checks (features/agents/roster.ts) and the ordered queue of record changes and timer progress (features/agents/record-changes.ts) moved out of the agents service, so the app and the program use one copy.
  • Programs it starts: only the ones each agent type declares (claude, or codex app-server), with the same PATH the app uses.
  • Saved state: the program reads and writes the app's agents/state/ files (feat(agents): keep agent state in files that a program outside the app can read #974) through the helper. It keeps a copy and reads the files again at each 5-second check, unless one of its own writes is still pending. So a change the owner saves in the app takes effect without a restart. A session that one side starts, the other continues.
  • Deleted agents: at each check, the program reads the agent list again. If the app deleted the agent, or its type, owner or relay changed, the program stops and exits. If sign-out keeps the agents, they keep running, because the app also restarts kept agents.
  • Name: buzz-agent-host, because the app already ships a different program called buzz-agent.

Same as the app

  • Team instructions come from the app's saved teams.
  • Mentions from relay workflows (fix(agents): accept verified relay workflow mentions #970) are accepted under the same rules. The program reads the community's workflow key from its NIP-11 information (self), as the app does, and checks the workflow creator's signed profile.
  • Solo chats: the program reads the channel's Solo label for each turn, so it answers and shows typing at top level, as the app does. It gives the turn the owner's Solo Canvas, read from the relay's writer.
  • Watch classifiers: the app does not run them yet either (classifier() is "unavailable" in both), so classified watches behave the same.

Security

  • Any process that runs as the same macOS user can start <app> agent-host <pubkey> and have it sign as that agent, but only the event kinds the app allows for agents. It cannot get the key. Before this change, the program read the raw key from the Keychain with the security command, which is worse.

Not in this program

Known limits (from adversarial review; not fixed here)

  • Takeover moment: each check holds .packaged shared for a moment. If the installed app tries its lock at that moment, it still runs the agent and tries again later, so both can answer one event. A dev build of the app already checks the same way, so this PR keeps that behavior.
  • Handover inside one app session: the app keeps the copy of the state files it read at startup. If it takes back an agent that this program ran while the app was open (in practice, a dev build), its next save of that agent can write over this program's newer state. With the installed app this does not happen: the app holds the agent whenever it runs, so this program runs it only while the app is closed, and the app reads the files when it starts.
  • It runs Buzz's own copy of Claude or Codex (from feat(agents): run Buzz's own copies of Claude and Codex, kept on the latest release #949), which the desktop app downloads and keeps current. It does not download a copy itself. If the app has not downloaded one yet, a turn fails with "Claude is not installed" or "Codex is not installed".
  • The helper is a new Buzz Desktop feature, so /Applications/Buzz.app must be a build that includes this PR.

🤖 Generated with Claude Code

@loganj
loganj force-pushed the agent-host-standalone branch from f0e5288 to 63721af Compare October 13, 2026 16:44
@loganj
loganj changed the base branch from agent-host-reads to agent-host-runner October 13, 2026 16:45
@loganj
loganj force-pushed the agent-host-standalone branch from 63721af to 827cf93 Compare October 13, 2026 17:06
@loganj
loganj changed the base branch from agent-host-runner to agent-host-state October 13, 2026 17:08
@loganj
loganj force-pushed the agent-host-standalone branch from 827cf93 to a848c82 Compare October 13, 2026 17:37
@loganj
loganj force-pushed the agent-host-standalone branch 3 times, most recently from 2469ead to ad53f6f Compare October 13, 2026 19:32
@loganj
loganj marked this pull request as ready for review October 13, 2026 19:48
@loganj
loganj requested a review from a team as a October 13, 2026 19:48
@loganj
loganj force-pushed the agent-host-standalone branch from ad53f6f to fde4b45 Compare October 13, 2026 20:18
@loganj
loganj force-pushed the agent-host-standalone branch from fde4b45 to d03ce7f Compare October 13, 2026 20:44
@loganj
loganj force-pushed the agent-host-standalone branch from d03ce7f to a9a56dd Compare October 13, 2026 21:14
@loganj
loganj force-pushed the agent-host-standalone branch from a9a56dd to a474233 Compare October 13, 2026 21:17
@loganj
loganj force-pushed the agent-host-standalone branch from a474233 to eab5259 Compare October 13, 2026 21:30
@loganj
loganj force-pushed the agent-host-standalone branch 3 times, most recently from 176fb8d to d730f9c Compare October 13, 2026 23:03
@loganj
loganj force-pushed the agent-host-standalone branch from d09a5c6 to 661ac1f Compare October 18, 2026 04:15
wesbillman

This comment was marked as resolved.

wesbillman

This comment was marked as resolved.

wesbillman

This comment was marked as resolved.

@loganj
loganj force-pushed the agent-host-standalone branch from 3880d2c to c726d6d Compare October 19, 2026 18:56
wesbillman

This comment was marked as resolved.

wesbillman

This comment was marked as resolved.

wesbillman

This comment was marked as resolved.

@loganj

This comment has been minimized.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account.

No new actionable findings in this follow-up; assessment 9/10 for the signing fix and rebase integration.

The standalone host now accepts shared-note kind 30023, matching the app custody allowlist. Its existing parity test still compares all 15 kinds, and kind 1 remains a valid rejected example. The shared note save/readback/conflict and delete paths retain their existing behavior. Earlier membership-recovery and Solo Canvas fixes are preserved: range-diff shows ten unchanged patches, one adapted only to Codex tab context, and the new signing patch.

Head cfdb393cdfad16078482a37cf55089277f2db00c; base 24bc02a81cc54c9a40d0bab6be8013235c8ddf3b. Follow-up to reviewed head da2c10ddacdd3740bcca1ccca2a90e37430fac5c, not a fresh exhaustive audit of unchanged code. Rocket independently checked systems/lifecycle, Mantis correctness/security/signing parity, and Groot product/error-retry flows; Star Lord integrated the evidence. The PR’s documented takeover and persistence limitations remain unchanged.

Public description (no image attachments) and new commit material inspected; no new disclosure finding. Source-only: no PR code, tests, builds or apps executed. One CI snapshot showed successful JavaScript/Rust checks and some browser jobs still running; Windows was skipped. Native handover, deployed relay acceptance/limits, performance, rendered focus/accessibility and human acceptance remain unverified. COMMENT only; no approval or merge authorization.

loganj and others added 17 commits October 23, 2026 10:04
Add buzz-agent-host, a Node script that runs one agent this machine's
app saved, with the same AgentRunners and Claude/Codex run code the app
uses. It reads the agent's identity from identities.json and its key from
BUZZ_AGENT_KEY or the app's Keychain bundle, listens on the agent's own
relay connection, reads the community as the agent, and follows the app's
hosts/ flock protocol so only one copy runs the agent.

It uses the app's agent storage (agents/state/): the agent runs with the
settings, team prompts and Claude or Codex sessions the app saved, reads
its record again every tick so edits made in the app apply, and writes
only its own agent's record.

Build with `pnpm agent-host`; run with
`node dist-agent-host/buzz-agent-host.mjs run <pubkey>`.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
A DM's first messages wait for its metadata, so an untagged message
from the owner wakes the agent as in the app. Subscriptions the relay
closes after the login are asked for again, with a growing wait, unless
it refused them. Channels are heard in batches of 128, the most the
relay accepts in one request.

When a process exits, stopped or not, its process group is killed too,
as host_process.rs does, so a tool it left running cannot outlive the
agent's claim.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
- While one subscription is asked for again, the open ones still hear
  messages. The feed now holds them until it is listening again, and
  then gives them to the host. Before, the host dropped them.
- Each subscription that closes keeps where it stopped hearing. Its new
  request asks from there, until the relay has sent what it missed.
  Before, traffic on another subscription moved that point on, so a
  long outage skipped messages.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
The feed gave what it heard while not listening as live events. The
runner marks a live event seen before it queues it, so a burst past the
agent's queue (32) or rate budget was dropped for good.

Those events, and what a re-requested subscription sends before its
EOSE, now come as a replay. The runner keeps a replay in its backlog
and admits it as the agent has room.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Catch-up progress now has one key per agent, so the old reason, a
shared value the app rewrote whole, no longer applies.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
The buzz.solo label on channel metadata is the one Solo fact, which every
member and agent reads. The host now reads it fresh for each turn, so an
agent taken over from the desktop answers an existing Solo chat as one
top-level conversation, and threads again once the chat becomes a channel.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
…batch

The relay refuses a channel batch only once the agent can read none of
its channels. The feed stopped being live until the next connection, so
mentions and timers in every other channel stopped. A refused batch no
longer counts toward being live, and is asked for again when a roster
shows the agent back in one of its channels.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
After stopAll kills a process group, an orphan that has exited can stay a
zombie until its new parent reaps it, and signal 0 still finds it. The
test now reads the process state, so it no longer fails on CI runners
whose reaper is slow.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
The app now hands each Solo chat turn the chat's Canvas: its standing
instructions and the folders the agent works in. The standalone host
gave its runtimes no Canvas reader, so after the desktop app closed, a
Solo chat lost those instructions, Claude started without the chat's
folders, and an edited or cleared Canvas never replaced the one a
resumed conversation knew.

The host now reads the chat's newest Canvas as the agent, for each turn,
and hands it over only when the owner or an agent they attest wrote it,
with the same check the app uses. No Canvas tells the agent that there is
none; a failed read keeps what the turn knew.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
A turn read its chat's Canvas with an ordinary query, which a relay with
replica reads may answer from a replica that lacks the owner's newest edit
or clear. Ask for it with consistency: strong, as the app's Canvas reads
do; the relay's HTTP bridge then answers from its writer.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Two membership changes left a channel unheard. A new channel added to a
batch the relay had refused was never asked for. And a rejoined channel
whose batch was still open for its other channels was not asked for
again: the relay drops a channel the agent leaves from the subscription
without closing it, and the feed hears no roster that leaves the agent
out, so it could not tell.

Now a new channel, or a new roster of a channel the agent is in, asks for
its batch again as it is now, even if the relay refused it. A roster heard
again, as after a reconnect, asks for nothing.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Main's #1285 lets the app's agent custody sign kind 30023 so agents can
publish shared notes. The host's list must stay equal to it, and the test
that pins the two lists caught the difference after the rebase.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Main replaced the runner's Jev hooks with decisions and keyStatus, added
interrupted turns and gitAuth to the runner host, and lets custody sign
repository announcements (30617). The host keeps interrupted turns in
memory, as it keeps catch-up progress, and reports that Git access needs
the desktop app.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
The host had its own relay subscription client (feed.ts). The app already
has one: subscribeRelayTraffic in features/relay/live.ts, which the
desktop's agents hear through. The host now runs that client, logged in
as the agent with its owner's attestation, and the app's recovery owner
retries what fails.

What the host adds is small (listen.ts): it reads the agent's channels
from its rosters, with their types for DMs, and gives them to the
client's update(). It reads them again when the relay says the agent's
membership changed, when a channel is denied, and at the client's
periodic repair. Until the first read, membership is unknown, so the
runner holds watched events, and releases them once the read is done.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
The host no longer has its own live client and reads. It signs in to the
app's RelaySession as the agent (session.ts), with no writer and no
publishing on its socket. The agent's reads are the app's ownerReads on
that session, and its roster checks are the service's, moved to roster.ts
so both use one copy.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
The host had its own copy of the agents service's ordered change queue.
Both now use RecordChanges: one agent's changes run in order, timer
progress is queued behind them, and timers see progress not saved yet.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
buzz-agent-host now starts `<app> agent-host <pubkey>`, a headless mode
of the app's own executable that answers the app's agent commands as
JSON lines over stdin and stdout, with the same Rust code the window
uses. The agent's key no longer enters Node, and the Node copies of the
claim, storage, identity and kind checks are gone. The helper adds
login, read auth and memory reads as the agent, and Git access works.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
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.

3 participants