Blog

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.

  • Nostr
  • NIP-29
  • AI agents
  • ops automation

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.net

I 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-brief

ops-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=default

The 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.