August 15, 2026 · 13 min read
Nostr NIP-29 Relay: AI Agent Channels and a NIP-98 Replay Problem
Putting an AI agent team on a NIP-29 relay and verifying what is canonical and who may write. Two NIP-98 auth events signed in the same second share an id and get rejected as replays.
CONFIGURATION MISSING appeared on the Desktop screen. Below it, I saw Unknown agents (8).
I had put eight agents on the relay, but Desktop did not recognize them as its own. Correcting the configuration file made no difference. That file was not the canonical source.
In NIP-98 authentication, four of the eight requests also returned 401. The keys were not incorrect.
A response showing that the relay is alive alone does not prove group chat
A Nostr relay is a server that receives and forwards events. NIP-29 is a specification for handling groups and membership within that relay. The fact that the relay responds and the fact that it supports group chat are different things.
So I split the initial check into two parts: whether the two Argo Applications were Synced and Healthy, and whether the relay included 29 in supported_nips in its NIP-11 response. NIP-11 is metadata that tells us which NIPs the relay supports.
# CLAB-1 c1 원문: part6-material.md:17
kubectl get applications -n argocd buzz buzz-storage
curl -sS -H 'Accept: application/nostr+json' https://buzz.example.netI checked buzz and buzz-storage separately so that I would not mistake the relay and storage for a single unit. If either one was not synchronized, I could not guarantee both the moment an event was received and the moment it was stored.
| Checkpoint | Acceptance criterion | Operational risk being checked |
|---|---|---|
| Deployment status | Both Applications were Synced/Healthy |
The deployment was complete, but storage was not ready |
| Relay response | NIP-11 HTTP 200 | Metadata could not be read externally |
| Feature declaration | 29 was included in supported_nips |
NIP-29 groups were expected, but the server did not declare support |
Once I separated the fact that the relay responded from the fact that agents collaborated, the next checks followed naturally: whether the channel actually existed, whether events were stored, and whether the approving parties were restricted.
I verified whether the nine channels and the incident ledger grouped the same symptoms together
Channel names were routing contracts, not UI decoration. When I queried by the owner key, all of the following 9 channels had to be present.
# CLAB-1 c2 원문: part6-material.md:18
ops-inbox/community/apple-reviews/testflight/support/bugs/product-insights/approvals/daily-briefops-inbox was the operations entry point, while community, support, and apple-reviews were customer touchpoints. bugs, product-insights, approvals, and daily-brief covered the internal flows for classifying, approving, and summarizing incidents. testflight received test feedback through a separate path.
It was difficult to determine from relay messages alone whether the same symptom had been submitted multiple times. I placed cases, case_sources, case_events, approvals, and product_signals tables in Postgres, separating the incident's current state from its original input. I also included the case_id unique constraint and indexes in the verification scope.
| Input | Stored result | Relay result |
|---|---|---|
| Discourse topic | 1 row in cases with source=discourse, source_id=<topic id> |
Case posted to #community |
| One email | Row created in cases with source=mail |
Relayed to #support |
| ASC review fixture | Case created along with cursor storage | Posted to #apple-reviews and #approvals |
This structure was needed when the same symptom arrived separately through Discourse and email. The cases table grew by only 1 row, while 2 rows were added to case_sources. I recorded that the two inputs had been merged into the same incident through a duplicate_of/merge record.
| Input checked | Change in cases |
Change in case_sources |
Record retained |
|---|---|---|---|
| Discourse + mail, same symptom | 1 | 2 rows | duplicate_of/merge |
| Different customer touchpoints | 1 per incident | Row per original source | source and source_id |
This was not simple deduplication. It was a ledger design that sent one incident to the responsible channel while still making it possible to trace later which touchpoint it had come through. From c4 through c7, I verified the topic webhook, mail polling, ASC cursor and fixture, and duplicate merging through their actual paths.
I restricted approval to the owner key and state transitions, not prompts
The presence of an approval message in #approvals did not grant execution authority. Acceptance criterion c9 required me to run the owner key's approval and an identical approval attempt with a non-owner key side by side.
| Actor | Approval result | approvals state | executor |
|---|---|---|---|
| owner | Approval allowed | Transitioned to approved |
Executed |
| non-owner | Rejected | State unchanged | Not executed |
It was not enough for the non-owner's attempt to end with an error message. The state had to remain unchanged. If the state changed to approved first and only execution failed, I would have needed additional code to revert it.
MCP (Model Context Protocol) was the connection specification I used for agents to discover and call tools. The tool list itself had to define the role boundary. I allowed cases-mcp's tools/list and case lookup, but community-mcp's tools/list had to contain no email-sending tool.
# CLAB-1 c8 원문: part6-material.md:24
cases-mcp와 community-mcp에 MCP tools/list 요청 및 cases-mcp로 케이스 조회| MCP check | Expected result | Meaning |
|---|---|---|
cases-mcp case lookup |
Case data returned | The read path worked |
community-mcp tool list |
No email-sending tool | The write boundary for the community role |
| Owner approval | State transition and executor execution | The approving actor was valid |
| Non-owner approval | No state change | The authorization error did not corrupt the state |
After I completed this check, I distinguished an agent writing to a channel from actually doing something externally. The relay's message permissions, MCP's tool permissions, and the approval ledger's state permissions occupied separate layers.
I changed the canonical source for agent configuration from the local cache to relay events
Desktop did not read managed-agents.json as the canonical source. This file was a cache, while the canonical source was the relay’s NIP-AP events. This was why Desktop displayed CONFIGURATION MISSING and Unknown agents (8) when no definition events existed.
In NIP-AP, kind 30175 is the definition event and uses d=slug. Kind 30177 is the instance event and uses d=agent pubkey. For “definition-less instances,” which are instances not linked to a definition, the definition fields had to be included directly in the 30177 event as an exception.
# BUZZ-3 원문: part6-material.md:41-42
동기화 구독: {kinds:[30177], authors:[self]}
kind:30177, tags=[["d", agent_pubkey]], content={name, system_prompt(페르소나+팀헌장), model, provider, parallelism, respond_to, respond_to_allowlist}The definition event had to be published using the Desktop identity’s key to match this subscription. When I published a definition with an arbitrary key, Desktop did not recognize it as its own event even though the event existed on the relay. During the actual resolution, I read the identity entry from buzz-desktop/secrets in the keychain, confirmed the derived pubkey, and then published the definition.
After restarting, Desktop read the relay definitions and created its own records. The models were displayed as sonnet, opus[1m], gpt-5.6-luna[xhigh], and gpt-5.6-sol[medium], and last_error was null for all of them. I deleted the manually added clab:* caches and supplemented the execution settings in the records Desktop created with slug=agent pubkey.
| Event | Purpose | Key or identifier | Exception |
|---|---|---|---|
| kind:30175 | Agent definition | d=slug |
General definition |
| kind:30177 | Agent instance | d=agent pubkey |
If definition-less, include the definition fields directly |
| Desktop synchronization | Receive its own definitions | authors:[self] |
Must be published using the identity key to be received |
Desktop recreated the three built-ins by reading relay events rather than a configuration file. Instead of deleting them completely, I set is_active=false.
I cut off the point where NIP-98 re-signing in the same second became a replay with a nonce
The longest debugging session occurred with NIP-98 authentication. The authentication event’s id is determined by (pubkey, created_at, kind, tags, content). When I signed twice with the same u and method in the same second, the event contents became identical and produced the same id. The relay rejected the second event as a replay.
Four of the eight requests initially returned 401. It was an easy number to interpret as faulty authentication, but the problem was with the inputs to the event id rather than the signing key or URL. After I added a nonce tag to the authentication event, all retries returned 200.
| Stage | Authentication event | Observed result |
|---|---|---|
| First request | Signed with u and method |
4 of 8 returned 401 |
| Retry in the same second | Signed again with the same fields | The same id was rejected as a replay |
| Add nonce | Added a nonce tag to the authentication event |
All retries returned 200 |
The second-level created_at was not time information. When I sent the same request again, it acted like an idempotency key. To create authentication that could be retried, the event contents had to differ each time I signed.
There were also two Python namespace collisions on the first attempt. The pubkey map overwrote the curve constant P, and the keychain dict overwrote the secrets module. I restored the names and brought the signing code back to life. This failure showed that, separately from the authentication algorithm, name management around the signing implementation also had to be verified.
I could say the team was operational only after passing ten checks
The checks were not simply whether “the chat room was visible.” I examined deployment, storage, routing, permissions, and execution separately.
| Criterion | Boundary examined | Pass condition |
|---|---|---|
| c1 | Relay deployment and NIP-11 | Both Applications Synced/Healthy, HTTP 200, NIP-29 |
| c2 | Group channels | 9 channels exist |
| c3 | Incident ledger | 5 tables, unique constraints and indexes |
| c4~c6 | External inputs | Discourse, mail, and ASC each feed into their respective channels |
| c7 | Duplicate merge | 1 case, 2 rows in case_sources, merge record |
| c8 | Tool permissions | No mail-sending tool in the community list |
| c9 | Approval permissions | Only the owner can transition states and execute; non-owners leave them unchanged |
| c10 | Team operations | 6 agent presences, triage routing, daily-brief |
The six in c10 and the eight in the Desktop registration record were different numbers. Since their validation scopes differed, I did not combine them.
The Relay Was Taken Down from the Cluster
A commit on August 14, 2026 removed the buzz relay and its media store from the cluster. There are no files beginning with buzz in argocd/applications/.
Instead, the justsend-care namespace was created a day earlier. The relay was there again. It was care-buzz, at wss://care.example.net.
Today, I queried that relay for NIP-11.
// curl -H 'Accept: application/nostr+json' https://care.example.net (2026-08-15)
{ "name": "Buzz Relay",
"software": "https://github.com/block/buzz",
"version": "0.2.1",
"supported_nips": [1,2,10,11,16,17,23,25,29,33,38,42,50,56,43] }29 was in the list. The verification item in Section 1 still held for the relocated relay.
The software value resolved one remaining question. The relay was not something we wrote; it was github.com/block/buzz. That explained why I could not find the NIP-29 implementation files in our repository. What we built were the agents and tools that run on top of it.
I counted what was in the namespace today. There were nine Deployments and two StatefulSets.
| What | Count | Name |
|---|---|---|
| Agents | 6 | care-agent-haram, etc. |
| Relay | 1 | care-buzz |
| Object store | 1 | care-buzz-minio |
| Incident ledger DB | 1 | care-db |
| Relay DB and cache | 2 (StatefulSet) | care-buzz-postgresql, care-buzz-redis |
There were two Postgres instances. The relay’s event store and the incident ledger’s case store were separate databases. The cases and case_sources from Section 2 belonged to care-db. Even if the relay was swapped out, the incident records remained.
The agents were there too. The ones that had run as resident processes on my Mac had become six Deployments.
| Deployment | CARE_ROLE |
MCP executable |
|---|---|---|
care-agent-haram |
lead |
care-mcp-lead |
care-agent-ria |
reviews |
care-mcp-reviews |
care-agent-daon |
community |
care-mcp-community |
care-agent-woojin |
mail |
care-mcp-mail |
care-agent-sena |
insight |
care-mcp-insight |
care-agent-taesan |
ops |
care-mcp-ops |
There was one image, ghcr.io/<org>/justsend-care-agent:0.3.0. The differences between roles were environment variables. The prompt file path, MCP executable name, and CARE_ROLE value differed. All six used claude-sonnet-5.
Writes were still disabled. CARE_WRITE_ENABLED=0, and CARE_CLUSTER_WRITE=0.
The current state of this branch was that the relay had been removed once, then rebuilt under a product name. What I learned from NIP-29 and NIP-98 had been carried over unchanged, while the execution location had moved from my Mac to the cluster.
I moved the targets to respond to from environment variables to a rules file
When I first moved it, the subscription mode was mentions. That mode received only messages that mentioned it. When I checked today’s live configuration, it had changed to config.
# care-agent-ria Deployment 환경 변수 (2026-08-15 읽기)
BUZZ_ACP_SUBSCRIBE=config
BUZZ_ACP_CONFIG=/etc/care/prompts/ria.rules.toml
BUZZ_ACP_RESPOND_TO=allowlist
BUZZ_ACP_PERMISSION_MODE=defaultThe subscription scope is determined by a file, not by a single environment variable. I read that file.
# ria.rules.toml — 생성기 산출물
[[rules]]
name = "direct"
channels = ["79419dba-…"] # 담당 채널 하나
kinds = [9] # 일반 메시지
require_mention = false # 멘션 없이 받는다
filter = '!str_starts_with(content, "//")'
[[rules]]
name = "mention"
channels = ["79419dba-…"]
kinds = [9, 46010, 40007] # 메시지·승인 요청·리마인더
require_mention = true # 부를 때만 받는다
There are two rules. It receives ordinary messages in the assigned channel without a mention, while it receives reminders and approval requests only when its name is called.
The filter line is a mechanism tailored to people’s habits. It treats lines beginning with // as self-talk and skips them. If the agent responded every time someone wrote a note in the channel, the channel would become unusable. It still receives the message when mentioned, because the second rule handles it.
If it received messages without mentions, it seemed like one message would wake all six agents at once. There were three reasons that did not happen.
| Why it does not wake them | Basis |
|---|---|
| Each channel is 1:1 with an agent | care-lobby is haram, and mail-desk is woojin |
| It does not subscribe to channels absent from the rules | The config mode only watches channels covered by a rule |
| It ignores its own posts | Default buzz-acp behavior |
The generator comments also state why kinds cannot be reduced to only 9. 40007 is a reminder, and 46010 is a workflow approval request. If only 9 remains, approval requests do not reach the agent.
These files are not written by hand. deploy/render-manifests.mjs creates them from the agent definition, and the first line of the file says, “Do not edit directly.” This follows the same direction as the fact that, in section 4, the source of truth was an event rather than a file. Here, the source of truth is the generator’s input, and the TOML is its output.