How Claude and Codex Learned to Tag Each Other
Block's Buzz gives AI coding agents their own keypairs and channel membership. A code-level walkthrough of how one agent wakes another up, with no orchestrator.
How Claude and Codex Learned to Tag Each Other
Block open sourced a chat app where AI coding agents are members, not features. Here is the machinery that lets one agent wake another one up.
You already know the workflow, even if you have never named it.
You prompt Claude Code. It writes a plan, then some code. Something feels off, so you want a second opinion from a different model. There is no way to ask for one directly, so you ask Claude to package its own work: the decisions, the open questions, the tradeoffs you have not resolved. You copy that summary. You open a Codex window. You paste it in. You read the reply. You copy that back into Claude.
Somebody described exactly this on a recent Presidio Bitcoin podcast episode:
"I would prompt something and then I would kind of feel like I wanted to get a second opinion on it. And so I would ask whatever agent, I'd be like, package this up into a summary of the decisions or questions or open issues so that I can copy and paste it into another client."
That workflow is real and it works. It also has one glaring property: you are the message bus. Two systems that both speak fluent English, both run on your machine, both have full access to the same repository, and the only way they can exchange a sentence is through your clipboard. Every handoff costs a context switch and happens at human speed. Close your laptop and the collaboration stops.
The same speaker described what replaced it:
"Claude finishes a cycle of specking and then it passes it off. It tags Codex in directly in line. Codex wakes up in response to that and starts building the thing, and then it finishes building and says here's what I am, and it tags Claude back."
That is Buzz. And that loop was running the entire time he said it, on two separate projects, on a laptop he had forgotten to plug in.
The claim, up front
Buzz removes the human from the middle by making the chat channel itself the runtime. Each agent gets a real cryptographic identity, joins a channel as a member, and subscribes to events in that channel. An @mention is not UI sugar. It is a tag inside a signed event, and a background harness is watching for exactly that tag. When Claude's message carries a tag pointing at Codex's public key, Codex's harness matches it, builds a prompt, and starts a session. No orchestrator, no queue service, no vendor integration.
Four pieces make that work, and each one is worth understanding on its own:
- A protocol that speaks to every agent the same way (ACP).
- An identity model where an agent is the same kind of thing as a person (a keypair).
- A wake-up mechanism built out of message tags (subscription rules).
- Personas that make two agents behave like two different colleagues rather than two copies of the same one.
Buzz is open source under Apache 2.0 at github.com/block/buzz, and it is not small: 28 Rust crates, roughly 274,000 lines of Rust in the workspace plus another 143,000 in the desktop app's Tauri backend. The crate that does the agent bridging, buzz-acp, is 40,869 lines by itself. Everything below is checkable against that tree.
Mechanism 1: one plug that fits every agent
Start with the boring problem, because it is the one that usually kills this kind of product. Claude Code, Codex, and goose are three separate programs from three separate organizations, with different CLIs, flags, output formats, and ideas about what a "session" is. Writing a chat app that drives all three means writing three integrations, then rewriting all three every time a vendor ships a release.
Buzz does not do that. It speaks ACP, the Agent Client Protocol, created by Zed Industries in late August 2025 to replace the terminal-scraping hack Zed had been using to drive Gemini CLI. The analogy in Zed's announcement is the useful part:
"Just as the Language Server Protocol unbundled language intelligence from monolithic IDEs, our goal with [ACP] is to enable you to switch between multiple agents without switching your editor."
Mechanically it is simple. ACP is JSON-RPC 2.0. A local agent runs as a subprocess of the client and the two talk over stdio. The method set is small enough to list:
| Method | Direction | Purpose |
|---|---|---|
initialize | client to agent | negotiate protocol version and capabilities |
session/new | client to agent | start a session with a working directory and a list of MCP servers |
session/prompt | client to agent | send a user turn, starts a "prompt turn" |
session/update | agent to client | streaming notification: message chunks, tool-call status, token usage |
session/request_permission | agent to client | ask approval before running a tool |
session/cancel | client to agent | abort the in-flight turn (a notification, not a request) |
If you have wondered how ACP relates to MCP: MCP connects an agent to its tools. ACP connects a client to the agent. They compose rather than compete, and the composition is explicit in the spec, because session/new is where the client hands the agent a list of MCP servers to connect to.
Now the Buzz-specific part, which comes with a surprise. Buzz does not depend on the official agent-client-protocol Rust crate. It appears in no Cargo.toml and no Cargo.lock; the only trace of it in the tree is a doc comment in a test citing the upstream schema file. Instead, crates/buzz-acp/src/acp.rs opens with its own description of what it is:
"ACP client module — manages communication with an AI agent subprocess over stdio using JSON-RPC 2.0 (newline-delimited / NDJSON)."
It frames messages with tokio_util::codec::LinesCodec over the child process's stdout and writes newline-terminated JSON back to its stdin. Hand-rolled, on purpose. There is a second wrinkle in the handshake worth appreciating for its candor. Buzz sends "protocolVersion": 2 in initialize, with this comment sitting at acp.rs:599:
"Requesting version 2 is an intentional temporary pin — we are squatting on ACP v2 ahead of the upstream ACP RFD. Revisit when that RFD merges."
Upstream, ACP v1 is stable and v2 is still a draft. Block is running ahead of the spec and saying so in a code comment rather than a blog post.
Finding the agents you already installed
The podcast's framing was that Buzz "automatically detects" whatever agents you have installed. That is accurate, and the implementation is refreshingly unmagical.
desktop/src-tauri/src/managed_agents/discovery.rs holds a static table, KNOWN_ACP_RUNTIMES, with four built-in entries: goose (command goose), claude (claude-agent-acp or claude-code-acp), codex (codex-acp), and Buzz's own minimal buzz-agent. Each carries an auth probe so the UI can tell you whether you are actually logged in: ["claude", "auth", "status"], ["codex", "login", "status"].
Resolution walks a fallback chain in resolve_command_uncached: workspace-local override, Buzz-managed npm bin directories, $PATH, Windows .cmd shims, then a login-shell probe that spawns /bin/zsh -l -c 'command -v ...' to catch PATH entries your interactive shell adds but a GUI app never sees, then the usual install directories, then nvm. That login-shell fallback is the correct answer to "why does my terminal find codex-acp but the app does not," and it is the sort of thing you only write after bug reports.
One nuance the podcast glossed. A guest said "Goose, Anthropic, and OpenAI all support" ACP. Goose is first-party: Block builds it and co-leads ACP's Transports Working Group. Claude Code and Codex reach ACP through adapter packages (claude-agent-acp, codex-acp) published under the ACP project's own org, which wrap the vendor CLIs. They are not code Anthropic or OpenAI ship inside their own binaries. The interoperability is real, but it lives one layer out from the vendors.
Mechanism 2: an agent is a member, not a bot
Every chat platform has bots, and bots are second-class by construction: an API token instead of an account, a webhook instead of membership, and a little "APP" badge in the UI meaning "do not treat this as a person."
Buzz makes a different call, and it is the decision everything else hangs off. From the README:
"Same shape, same identity model, same audit trail, whether the author is a person or a process."
The identity primitive is a secp256k1 keypair, the same one Nostr uses. Your identity is your public key, displayed as npub1.... An agent's identity is also a public key, also displayed as npub1.... No user table grants one of them a special row type. An agent authenticates to the relay the way you do, by signing a challenge (NIP-42), and signs every message with its own private key. An agent process gets that key from the environment:
export BUZZ_RELAY_URL="ws://localhost:3000"
export BUZZ_PRIVATE_KEY="nsec1..." # or hexThree consequences follow, and they are why this is not merely an aesthetic choice.
Attribution is cryptographic, not administrative. A message from your Codex agent carries a Schnorr signature over the body, re-verified by the relay before storage. Nobody posts as your agent without its key. Compare a webhook bot, where "who sent this" is whatever the sending service claims.
Agents are addressable the way people are. With a pubkey and channel membership, an agent appears in the member list, can be @mentioned, and can be added or removed with the same commands used for humans. On the podcast this landed as: you can walk into someone else's channel and address their agent "just like I'm addressing somebody in Slack." It works because there is nothing to special-case.
The audit log needs no separate concept for machine actions. Buzz keeps a SHA-256 hash-chained, tamper-evident audit log. An agent's writes land in it with the same shape as a human's, so "what did the AI do in this repo last Tuesday" is the same query as "what did Priya do."
Agent-specific state is then layered on top of that shared identity rather than beside it: kind 10100 is an agent profile, 30174 holds "engrams" (encrypted long-term agent memory), 30177 describes a managed agent, 44200 records per-turn metrics. Additions to a member, not a parallel bot system.
Mechanism 3: the @mention is the wake-up call
This is the section that answers the actual question, so every link in the chain gets spelled out.
What a message actually is
Buzz stores everything as Nostr events. An event is a JSON object with seven fields, and that is the entire data model:
{
"id": "<sha256 of the canonical serialization>",
"pubkey": "<author's public key, hex>",
"created_at": 1786000000,
"kind": 9,
"tags": [["h", "<channel-uuid>"], ["p", "<mentioned-pubkey>"]],
"content": "@codex the spec is in docs/plan.md, go build it",
"sig": "<schnorr signature>"
}The kind integer is the only dispatch switch in the system. A channel chat message is kind: 9 (KIND_STREAM_MESSAGE, borrowed from NIP-29 group chat). A reaction is kind: 7. Presence is kind: 20001 and never touches disk. Buzz's registry, crates/buzz-core/src/kind.rs, defines well over a hundred kinds, banded by range: 0 to 9999 for standard Nostr, 10000 to 19999 and 30000 to 39999 for replaceable and addressable events, 20000 to 29999 for ephemeral, and 40000 to 49999 for Buzz's own.
The part that matters here is tags. A ["p", "<pubkey>"] tag means "this event references that pubkey," which in chat terms is a mention. When you type @codex in the composer, the client resolves the display name to a public key and attaches a p tag. The @codex text in content is for humans to read. The p tag is the part the machine acts on.
What is listening
Each agent runs under the buzz-acp harness. The harness holds an authenticated WebSocket to the relay and evaluates every event that arrives against an ordered list of subscription rules. Here are that rule type's user-facing fields, from crates/buzz-acp/src/filter.rs:
pub struct SubscriptionRule {
/// Human-readable rule name; used as fallback `prompt_tag`.
pub name: String,
/// Which channels this rule applies to.
pub channels: ChannelScope,
/// Nostr event kinds to match. Empty = wildcard (all kinds).
pub kinds: Vec<u32>,
/// If `true`, the event must contain a `p` tag referencing the agent pubkey.
pub require_mention: bool,
/// Optional evalexpr boolean expression for fine-grained filtering.
pub filter: Option<String>,
/// Tag passed to the prompt template. Falls back to `name` if absent.
pub prompt_tag: Option<String>,
// ... (serde attributes and two internal fields elided)
}Read require_mention again, because it is the whole trick: the event must contain a p tag referencing the agent pubkey. That is "Claude tags Codex," expressed as one boolean. Rules are evaluated in order, first match wins, and require_mention defaults to false, so a mention is the opt-in rather than the assumption. ChannelScope is either the literal string "all" or a list of channel UUIDs, so an agent can be a workspace generalist or scoped to one room.
The optional filter field is where it gets expressive: a boolean expression evaluated with evalexpr against a context built from the event (content, author, kind, channel_id, timestamp). A rule can say "wake me for any message in this channel containing deploy that did not come from me."
Because those expressions run on the hot path, the harness treats them as hostile. Capped at 4096 bytes (MAX_EXPR_LEN). A hard 100ms budget per evaluation (EVAL_TIMEOUT). At most 4 concurrent evaluations, bounded by a semaphore whose permit is held until the blocking thread actually finishes rather than until the caller's timeout fires. And a rule that times out repeatedly is disabled: once its AtomicU32 counter reaches MAX_CONSECUTIVE_TIMEOUTS, the rule stops matching. Fail closed, not fail open. A pathological filter makes an agent go quiet. It does not make the harness spin.
From tag to prompt
Once a rule matches, the harness builds a prompt and sends session/prompt. The prompt is assembled from labelled sections, and you can see the exact framing in crates/buzz-acp/src/queue.rs:
[Base] <- the harness-wide platform preamble
[System] <- the agent's persona system prompt
[Team Instructions]
[Context] <- scope, channel name, channel UUID
[Buzz event: @mention]
Content: @codex the spec is in docs/plan.md, go build it
The [Base] block is a checked-in file, crates/buzz-acp/src/base_prompt.md, and its first paragraph tells the agent what kind of thing it is:
"You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session."
The session model is the interesting part:
"You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Sessions share your core memory, your workspace on disk, and the relay. They do NOT share conversation context, in-progress reasoning, or in-context task state."
So "Codex" in your workspace is one identity with N live conversations. The base prompt goes further: if a human here refers to work "you" are doing elsewhere, that belongs to a different session, so answer from what you can verify and leave execution with the session that owns it. An explicit anti-thrash instruction, written because the failure mode is obvious once two sessions of the same agent race on the same repository.
How the agent talks back
The reply path is not a callback. The base prompt hands the agent a CLI and expects it to use it:
| Group | Key commands |
|---|---|
buzz messages | send, get, thread, search |
buzz channels | list, get, create, join, members |
buzz repos / buzz pr / buzz issues | create, open, update, status |
The agent posts by shelling out to buzz messages send, signing with its own key. So the agent's reply is an ordinary kind: 9 event with ordinary tags, and if it mentions another agent, that event carries a p tag, and the loop closes. Codex replying "done, @claude review it" is not a special orchestration primitive. It is the same code path as a human typing the same sentence. Code changes get their own variant, KIND_STREAM_MESSAGE_DIFF (40008), truncated at a hunk boundary at 60 KiB.
This has one consequence that surprised me. An ACP session/update stream does not become a chat message. The harness logs those chunks; it does not auto-post them. Everything the channel sees, the agent had to decide to publish. Buzz's own agent enforces that with a nag: is_reply_shaped() checks whether the turn included a shell call containing messages send or reactions add, and if not, injects up to MAX_REPLY_NAGS = 2 reminders worded like this:
"You are about to end this turn without calling
buzz messages send. Your assistant text and reasoning are never shown to anyone — if you did work, found an answer, or hit a blocker that someone is waiting on, it exists only if you publish it."
The same constant ends with "if silence is genuinely correct for this turn, ignore this and end your turn." Silence is tolerated, not blocked, which is a deliberate hedge against every agent narrating every thought into a shared room.
What happens when a message arrives mid-turn
Real chat is interruptive, and this is where a naive implementation falls over. Buzz's answer is a struct in queue.rs called MergeFraming, which picks different prompt wording depending on why the in-flight turn was cancelled.
If the reason is Steer (the default when a new message simply shows up while the agent is working), the prior request is re-presented under [What you were working on], the new message under [New message — arrived while you were working], and the turn ends with:
"Note: A new message arrived while you were working. Continue your in-progress work and incorporate the new message if it's relevant; if it's unrelated, you may briefly acknowledge it and carry on."
If the reason is Interrupt, the framing hardens: [Previous request — interrupted before completion] and [New request — supersedes previous].
A comment beside that code is honest about why the wording is careful:
"We never capture the agent's partial work —
session/cancelis terminal and returns nothing — so this section holds the original request, not a transcript. The header must not overclaim preserved state (per Dawn's framing review)."
ACP cancellation throws away the partial turn. Rather than pretend otherwise, the prompt never tells the agent it has state it does not have.
Mechanism 4: personas are what make two agents worth having
Two instances of the same model, given the same prompt, produce correlated output. A second opinion is only worth the tokens if it comes from somewhere genuinely different. Buzz's answer is role separation, configured per agent. Here is a working setup, described on the podcast:
"I have this set up with Claude, you're going to do all of the creative decisions, all the design. I want you to do no building or primarily don't focus on actual writing of code, but focus on all the design, the trade-offs and the features and focus on getting to a really good spec. And then I'm going to have Codex... I want you to tag Codex in."
In the codebase this is the buzz-persona crate. A Persona Pack is described in PERSONA_PACK_SPEC.md as "a portable, self-contained bundle that defines one or more AI agent personas," structured as a superset of the Open Plugin Spec with a .plugin/plugin.json manifest. Resolution (resolve_pack, resolve_persona_by_name) produces a ResolvedPersona whose system_prompt flows into ACP's session/new.
Delivery of that prompt is per-runtime, and the branching is where the leaky part of the abstraction shows. session_new_system_prompt() in pool.rs has three arms. buzz-agent and other protocol-v2 agents get SystemPromptTransport::Field, a bare systemPrompt parameter. Claude gets SystemPromptTransport::ClaudeMeta, which sends _meta.systemPrompt.append specifically so the adapter keeps its native preset and your persona layers on top instead of replacing it. And goose gets nothing at all here: it is excluded from session/new and receives its prompt afterward through a separate session_set_goose_system_prompt call. A shared protocol does not make these agents interchangeable, and Buzz stops pretending it does at exactly the point where it matters.
The result is what one of the hosts called a "council of experts" and what most teams would just call a team: a designer who is not allowed to write code, a builder who is not asked to redesign, and a third seat for a different frontier model when you want an uncorrelated read.
The loop, end to end
Putting the four mechanisms together, here is one full cycle:
You (human) Relay Claude harness Codex harness
| | | |
|-- kind:9 + p:claude -->| | |
| |-- event ------------->| |
| | rule match: |
| | require_mention |
| | | |
| | session/prompt (ACP, NDJSON) |
| | |--> claude-agent-acp |
| | |<-- session/update |
| | (streamed chunks) |
| | | |
| |<-- buzz messages send --| |
| | kind:9 + p:codex | |
| |------------- event ------------------------>|
| | rule match
| | session/prompt --> codex-acp
| | |
| |<---------- buzz messages send --------------|
| | kind:9 + p:claude |
|<-- event (you lurk) --| |
Nothing in that diagram is an orchestration engine. The "orchestrator" is a p tag and a require_mention boolean, twice.
Where all of this actually lives
On the podcast, a host asked where the data is stored, got the answer "Nostr," and reasonably objected that this could not possibly be cost-effective. The clarification is the important part:
"It uses Nostr, but you can back it with a SQL database... You can run your own relay with your own database."
Worth being precise about, because "built on Nostr" makes people picture a swarm of public relays. Buzz is not that. From ARCHITECTURE.md:
"The relay is the single source of truth. All reads and writes flow through it. There is no peer-to-peer event exchange, no gossip, no replication — just clients connecting to one relay over WebSocket..."
Nostr is the wire format and the identity scheme. Postgres is the database. The relay (buzz-relay, an Axum WebSocket and HTTP server, about 67,000 lines) writes events into a Postgres 17 events table range-partitioned by month, fans them out through Redis pub/sub, indexes them with Postgres full-text search, and stores media in an S3-compatible bucket over the Blossom protocol under per-type caps (50 MB images, 10 MB GIFs, 500 MB video, 100 MB everything else). Ephemeral kinds never touch disk, and auth events (kind 22242) are never stored at all.
What the Nostr choice buys is not decentralization. It buys extensibility without versioned APIs (new feature, new kind integer, older clients ignore what they do not recognize), and an identity model where a person and a process are the same kind of object, which is what made mechanism 2 possible. You can run the whole thing yourself:
git clone https://github.com/block/buzz.git && cd buzz
. ./bin/activate-hermit # pinned toolchain
just setup && just build # docker services, migrations, workspace build
just dev # relay on ws://localhost:3000 + desktop appFor real deployments there is a Compose bundle in deploy/compose/ (relay image ghcr.io/block/buzz, Postgres, Redis, MinIO, optional Caddy for TLS) and Helm charts under deploy/charts/.
What it costs
Here is what the demo does not cover. To the repo's credit, most of it is documented by Block itself rather than discovered by me.
There is no end-to-end encryption, by design. VISION.md states it plainly: "Server-managed encryption covers every channel, every DM, every event — eDiscovery works on everything. End-to-end encryption (NIP-44) is a future consideration for DMs." The relay operator can read your channels. For an enterprise that needs compliance discovery, that is the feature. Where NIP-44 encryption is used (agent memory, metrics, DM payloads), docs/nips/NIP-AM.md says the quiet part out loud: "NIP-44 does not provide forward secrecy; compromise of the agent's private key allows decryption of captured ciphertexts." No MLS, no double ratchet, no post-compromise recovery anywhere in the tree.
Rate limiting is designed but not enforced. A four-tier RateLimitConfig exists (human, agent-standard, agent-elevated, agent-platform), but ARCHITECTURE.md notes there is no Redis-backed limiter anywhere in the codebase. Fine on a private relay. A gap you fill yourself on anything reachable.
Tool permissions are auto-approved at the harness layer. handle_permission_request() (acp.rs:1896) intercepts every ACP session/request_permission, scans the agent's offered options for the one whose kind is allow_once, and replies with that option's ID, falling back to reject_once only if no allow option exists. The prompts a human would answer inside Claude Code or Codex interactively are answered by the harness instead. The human gate in this system is agent creation (buzz agents draft-create opens an owner-reviewed draft in Desktop), not agent action.
And Buzz overrides Codex's own sandbox. build_codex_config_env() force-sets sandbox_workspace_write.network_access = true regardless of the persona config, with the comment "our invariant, always wins" (acp.rs:338). Network access from inside the workspace sandbox is required for the product to function, but it is a restriction Codex offers and Buzz declines. Whatever isolation you want around an unattended agent, you supply yourself. The Dockerfile.sprig container (harness, agent, and dev MCP server in one binary with rg, tree, and Nostr-signed git) is the intended answer.
Lose the key, lose the identity. There is no password reset for a keypair. Buzz has a QR-code device-pairing extension (NIP-AB), whose own spec notes "there is no mechanism to invalidate a completed pairing," and an identity-archival extension (NIP-IA) that hides a retired pubkey without rotating it. VISION_SOVEREIGN.md states the tradeoff directly: the property that makes your identity uncensorable makes it unrecoverable.
And the obvious one: two frontier agents in an unattended loop spend money. The harness tracks per-turn token usage (TurnUsage, and session/update carries usage_update with counts and optional cost) precisely because this is worth watching. A spec cycle that hands to a build cycle that hands back for review is three model calls per iteration, and nothing in the design stops it at iteration nine.
The part that is actually new
Strip away the protocol details and one property is left that I have not seen elsewhere, and it is not "agents can talk to each other." It is that the collaboration is legible to everyone in the room, while it is happening.
Open source has always claimed to work in public, but only from a certain point onward. The concept, the first prototype, the debugging cycles, the rewrite, the test vectors: all of it happens on one machine, invisible, because the culture of pull requests punishes sharing too early. One of the podcast guests drew the conclusion:
"With Buzz, you literally are working in public from inception."
A viewer in the chat put it more bluntly: "Just the ability to watch other people build is amazing and something lacking from every other vibe coding tool."
Because the agents work inside a channel and the channel is a normal chat room, a teammate can lurk, read the spec Claude produced, watch Codex implement it, and interject. They can also @mention your agent directly and ask "catch me up, where are we, what decisions are outstanding," and get an answer, because your agent is a member of that channel with full history and its own key.
Be precise about what "public" means, though. The blow-by-blow live feed (kind: 24200 observer frames, one per second, NIP-44 encrypted to the owner alone) stays private to whoever runs the agent. What the room sees is what the agent chose to publish. That is closer to watching a colleague work than to reading their inner monologue, and it is probably the right line.
Either way it is a different thing from a coding agent. A coding agent is a tool you hold. This is a coworker other people can talk to.
Whether Buzz specifically wins is a separate question, and there are reasons to doubt it: large surface area for a young project, documented holes in the security model, and a dependency on an ACP ecosystem that is months old with a v2 Block is already squatting on ahead of the spec. But the shape of the idea does not depend on Buzz surviving. Once you have watched an agent handoff happen as an ordinary message with an ordinary mention tag, orchestrating agents through bespoke Python glue looks like what it is: a workaround for the fact that our agents had no way to address each other.
They do now. It turns out all it took was giving each of them a name, a keypair, and somewhere to be mentioned.