Signed-off-by: cls_宁波本机 <908705107@qq.com>
25 KiB
NIP-AP
Agent Personas
draft optional
This NIP defines kind:30175 persona events — public, addressable definitions that describe how to instantiate an AI agent. A persona carries identity (display name, avatar), behavioral configuration (system prompt, model, runtime), and an optional name pool. It is the "blueprint" from which agents are spawned.
Kind
This NIP claims kind:30175 for agent persona definitions and kind:30178 for the shareable team-catalog projection (see "Team catalog projection: kind:30178"). Both are in the NIP-33 parameterized replaceable range (30000–39999) per NIP-01: addressed by (pubkey, kind, d_tag), with only the latest event per address retained.
A dedicated kind (rather than encoding personas as NIP-78 kind:30078 "Application-specific Data") is taken for the same reasons as NIP-AE: (1) it isolates this NIP's address space from any other application using the same pubkey — persona slugs cannot collide with another app's d tag choices; (2) it lets observers, indexers, and unknown-kind viewers identify persona events from the kind alone, without parsing content as a namespace demultiplexer.
Roles
- owner — a Nostr identity (
pubkey_o) that publishes and manages persona definitions. Typically the workspace operator. - agent — a Nostr identity instantiated from a persona. Agents do NOT author persona events; they consume them. An agent MAY store a private snapshot of its originating persona in a NIP-AE engram at
mem/persona(encrypted, owner-readable).
Slugs
The d tag of a persona event is the plaintext persona slug. A valid slug matches:
^[a-z0-9][a-z0-9_-]{0,63}$
Total length: 1–64 bytes. Slugs are flat identifiers (no path separators), unlike NIP-AE memory slugs which are hierarchical (mem/…).
Plaintext rationale
The d-tag is deliberately NOT blinded (contrast with NIP-AE which HMAC-blinds d-tags to protect memory slug confidentiality). Personas are public definitions meant for discovery:
- Direct filter queries:
{kinds: [30175], authors: [pubkey], "#d": ["my-persona"]} - Human-readable addressing in UIs
- Cross-workspace sharing without a shared secret
Event envelope
{
"kind": 30175,
"pubkey": "<pubkey_o>",
"created_at": <unix_seconds>,
"tags": [
["d", "<persona-slug>"]
],
"content": "<json_body>"
}
There MUST be exactly one d tag and it MUST contain a valid slug per the grammar above. The relay enforces this constraint on ingest. There is no p tag — persona events are owner-to-self definitions, not directed at a counterparty.
Implementations MAY include a NIP-31 ["alt", "agent persona definition"] tag to give unknown-kind viewers a non-leaking summary. Additional tags beyond d and alt are not defined by this NIP and have no effect on validity.
Content body
The content field is a plaintext (unencrypted) JSON object:
{
"display_name": "<string>",
"system_prompt": "<string | null>",
"avatar_url": "<string | null>",
"runtime": "<string | null>",
"model": "<string | null>",
"provider": "<string | null>",
"name_pool": ["<string>", ...],
"respond_to": "<string | null>",
"respond_to_allowlist": ["<64-hex pubkey>", ...],
"parallelism": "<integer | null>"
}
Required fields
| Field | Type | Description |
|---|---|---|
display_name |
string | Human-readable name for the agent definition. |
Optional fields
| Field | Type | Default | Description |
|---|---|---|---|
system_prompt |
string | null | null |
The system prompt injected into agent sessions. Optional since the unified agent model: a definition can be pure configuration (e.g. provider/model only). Readers MUST treat an absent or null prompt as "no prompt". |
avatar_url |
string | null | null |
URL to an avatar image. |
runtime |
string | null | null |
ACP runtime identifier (e.g. "goose", "claude-code"). |
model |
string | null | null |
Model identifier (e.g. "claude-opus-4"). |
provider |
string | null | null |
Model provider (e.g. "anthropic"). |
name_pool |
string[] | [] |
Pool of display names for agent instances spawned from this definition. When non-empty, the spawning system picks a name from this pool for each new agent instance, enabling multiple concurrent agents from the same definition to have distinct identities. |
respond_to |
string | null | null |
Reserved. Default respond-to policy for instances spawned from this definition: "anyone", "owner-only", or "allowlist". null defers to the client default. |
respond_to_allowlist |
string[] | [] |
Reserved. Allowlisted author pubkeys (64-char lowercase hex) when respond_to is "allowlist". Ignored otherwise. |
parallelism |
integer | null | null |
Reserved. Default max concurrent turns for spawned instances. null defers to the client default. |
The behavioral fields (respond_to, respond_to_allowlist,
parallelism) are definition-level defaults: a spawned instance copies them
at creation and may be reconfigured independently afterwards. They were
previously carried only on the kind:30177 projection (see
"Slimming: kind:30177" below).
Status: reserved. In the current implementation these behavioral fields are parsed but not yet applied: readers tolerate and preserve them at the wire layer, but the local definition store does not yet carry them and writers do not emit them. The instance-copy-at-creation behavior activates in a subsequent release (the create-path unification). Until then a definition carrying these fields round-trips through the wire type but the values do not survive a local edit-and-republish cycle.
Unknown fields MUST be ignored by readers (forward compatibility).
Prohibited: secrets in content
The content body is public and unencrypted. It MUST NOT contain secrets (API keys, tokens, credentials, or any sensitive environment variables). In particular, an env_vars field MUST NOT appear in the content body.
Secrets required by agents spawned from a persona MUST be conveyed through a separate encrypted channel — specifically, the NIP-AE engram at mem/persona (which is NIP-44 encrypted to the agent↔owner conversation key) or through out-of-band injection at spawn time.
Encryption rationale
Persona events carry no encryption. This is deliberate:
- Personas are configuration, not state. They describe what an agent should be, not what it has learned.
- Encryption would prevent relay-side indexing, search, and third-party client rendering — all desirable for definitions that workspace members should browse.
- Operators who need confidentiality should use relay-level access control (NIP-42 authentication + NIP-29 group membership) rather than event-level encryption.
Replacement semantics
Standard NIP-33: for a given (pubkey, kind:30175, d_tag), only the event with the greatest created_at is the head. Ties are broken by lowest event id per NIP-01. Relays SHOULD return only the head; clients MUST select the head from any multi-event response.
Writing
To write or update a persona with slug s and body b:
- Validate
sagainst the slug grammar. Reject if invalid. - Serialize
bto JSON. Reject if the serialized body exceeds 65,535 bytes. - Compute the head of
sper NIP-33 and letTbe itscreated_at(or 0 if no head exists). Setcreated_at := max(now, T + 1). Monotonicity ensures fresh writes always supersede prior heads regardless of clock skew. - Tags:
[["d", s]]. - Sign with
seckey_oand publish to configured relays.
Reading
To read a single persona by slug s:
Filter: {kinds: [30175], authors: [pubkey_o], "#d": [s]}
Select the head per NIP-33 rules. Parse content as JSON. Validate required fields.
To list all personas for an owner:
Filter: {kinds: [30175], authors: [pubkey_o]}
Returns all heads. Clients scope by author pubkey — two different owners MAY publish personas with the same slug; these are independent events.
Deletion
Owners MAY publish NIP-09 deletion requests targeting persona events. A deletion request MUST be authored by the same key (pubkey_o). Such requests SHOULD include ["k", "30175"] and use an a-tag identifier 30175:<pubkey_o>:<slug>.
A subsequent write with a later timestamp resurrects the slug under NIP-33 replacement semantics.
The same applies to kind:30178: a deletion request SHOULD carry ["k", "30178"] and the a-tag identifier 30178:<pubkey_o>:<team-id>. Unsharing is distinct from deletion — it is a newer valid head at the same coordinate published without the shared tag, which keeps the projection readable to its author while retracting it from foreign readers.
Relationships to other NIPs
NIP-AE (Agent Engrams)
Agents spawned from a persona MAY store a private snapshot at the reserved engram slug mem/persona. This engram:
- Is NIP-44 encrypted (confidential to agent + owner)
- MAY contain secrets (env vars, API keys) that the public persona event must not carry
- Serves as the agent's private, mutable copy of its originating configuration
- References back to the persona event by slug convention, not by event ID
The mem/persona slug conforms to NIP-AE's slug grammar and requires no amendment to that spec.
Slimming: kind:30177 (instance state)
Kind:30177 is keyed by agent pubkey (one event per instance) while kind:30175 is keyed by definition slug — they occupy different key spaces and serve different roles. 30177 remains the per-instance cross-device sync channel; with the unified agent model it is slimmed to carry only instance-level state:
- Writers MUST NOT include definition-level fields
(
system_prompt,model,provider,persona_source_version) in new kind:30177 events for definition-linked instances. Those resolve through the linked kind:30175 definition. Writers continue to publish instance-level fields (name, linked definition id,respond_to+ allowlist,parallelism). - Exception — definition-less instances: an instance with no linked definition is its own definition; writers MUST keep emitting the definition-level fields for such instances. (Rationale: old readers parse a slimmed event successfully and would overwrite their local snapshot with absent values; a definition-linked instance self-heals from its definition at next spawn, but a definition-less one has no restore path.) This exception retires naturally once all instances are definition-backed.
- Readers SHOULD continue to accept legacy "fat" kind:30177 events during the transition. Where the linked 30175 head and a legacy 30177 event both carry a field, the 30175 head is authoritative.
- Deletion/retention rules for kind:30177 are unchanged so historical tombstones keep working.
Mixed-version note
Clients released before this revision require system_prompt in 30175
content and will fail to parse (and therefore silently drop) prompt-less
definitions published by newer clients. This is a benign divergence —
old devices simply do not see new-style definitions until upgraded — not
data corruption. Implementations SHOULD log dropped events rather than
surface per-event errors.
NIP-OA (Owner Attestation)
Agents spawned from a persona carry NIP-OA owner attestation — an auth tag proving that pubkey_o authorized the agent's key. The persona event itself does not contain attestation; it is the definition from which attestation is issued at spawn time.
Team catalog projection: kind:30178
Kind 30178 is the shareable projection of a team: owner-authored, parameterized replaceable, addressed by (pubkey_o, 30178, d) where d is the team's stable local id. Its content is a versioned JSON body carrying sanitized team fields plus ordered, embedded member definition projections. The content schema is defined by the client that publishes it; this section specifies only the envelope and the relay's contract.
{
"kind": 30178,
"pubkey": "<pubkey_o>",
"created_at": <unix_seconds>,
"tags": [
["d", "<team-id>"],
["shared", "true"] // optional; presence opts the projection into community reads
],
"content": "<json_body>"
}
Why a separate kind rather than a shared tag on the team event (kind:30176). A team's members are kind:30175 definitions, which are author-only unless individually shared — so a foreign reader of a shared team could never hydrate its members. Kind 30178 embeds the member projections instead of referencing them: the share is atomic, it covers built-in members that have no 30175 head at all, it is immune to local-id/d-tag divergence, and an unshared 30175 stays private. Kind 30176's wire body is untouched, so device sync keeps its contract.
The d tag is a team id, not a persona slug. It is either a UUID or a built-in identifier such as builtin-team:welcome. The colon is illegal under the persona slug grammar, and rewriting ids to fit would break NIP-33 addressing against the team's own kind:30176 head — so the relay applies a laxer rule (see below) to 30178 than to 30175.
Content carries only sanitized fields. No environment variables, no respond_to allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. Sharing a team makes the team's and every member's instructions community-readable plaintext.
Relay behavior
Ingest validation
- The relay MUST accept
kind:30175events that pass standard NIP-33 validation (valid signature, exactly onedtag with a non-empty value). - The relay stores persona events globally (
channel_id = NULL); they are not channel-scoped. - The relay is NOT required to validate that
contentparses as validPersonaEventContentJSON. Relays are dumb stores per Nostr convention; content validation is a client responsibility. - The relay MUST enforce that the
dtag is non-empty (standard NIP-33 requirement for parameterized replaceable events). - The relay MUST enforce shared-tag shape: if a
sharedtag is present, it MUST consist of exactly two elements —["shared", "true"]. Extra elements (e.g.["shared","true","extra"]), wrong values (["shared","false"]), missing values (["shared"]), or duplicatesharedtags are all rejected withinvalid:. The two-element exact-shape constraint is required so that the relay's SQL visibility clause (tags @> '[["shared","true"]]') never matches a stored malformed tag via JSONB containment supersets.
Ingest validation: kind:30178
Kind 30178 is stored globally and its content is unvalidated, exactly as for 30175. The envelope rules differ in one respect — the d grammar:
- The relay MUST enforce the same
shared-tag exact shape as30175, for the same reason: the read gate and the SQL containment clause must agree on every stored event. - The relay MUST enforce exactly one
dtag whose value is non-empty, at most 64 characters, and free of Unicode control characters and whitespace. Tags are counted by their first element, so a valueless["d"]counts toward the total and fails the value check on its own — otherwise["d"]alongside["d","<team-id>"]would pass, and a consumer that reads["d"]as an empty-valued firstdtag would address the event at""while this relay addresses it at<team-id>. Without the non-empty check, generic NIP-33 storage maps a missing or emptydto the empty coordinate, collapsing every team into the single(pubkey_o, 30178, "")slot — last-write-wins data loss. The character bound keeps the value usable as a NIP-33 coordinate and as a log field. - The relay MUST NOT apply the persona slug grammar to a
30178dtag; team ids legitimately contain characters (notably:) that the slug grammar forbids.
Access control: author-only-unless-shared
Kind 30175 uses shared-tag-gated read semantics to protect system prompts and respond_to_allowlist from being visible to all community members as a side-effect of device sync.
The gate is kind-generic: the relay applies it to every kind in SHARED_GATED_KINDS (buzz-core/src/kind.rs), currently 30175 and the 30178 team-catalog projection described below. The rules and enforcement surfaces are identical for each member kind.
Rules:
| Event state | Author reads | Foreign reads |
|---|---|---|
No shared tag |
✅ allowed | ❌ withheld |
["shared", "true"] tag |
✅ allowed | ✅ allowed |
These rules are enforced at the following relay read surfaces (content and event existence are withheld on all of them):
- REQ historical delivery — foreign requests silently omit unshared persona events, even in mixed-kind filters (
{kinds:[30175,9]}). The visibility check is applied beforeORDER BY … LIMITat the SQL level (shared_gated_readerfield inEventQuery), so a page of newer private personas cannot starve an older shared persona off the candidate set — the catalog's primary all-author query pattern is correctly served. - NIP-01
idslookup — knowing an event id does NOT grant access to an unshared persona. The result gate returns nothing. - Live fan-out — unshared personas are delivered only to the author's connections. Shared personas fan out community-wide.
- COUNT — the fast SQL
count_events()path is bypassed when the filter can match a shared-gated kind. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT. - NIP-98 HTTP bridge
/query— the same per-event visibility check is applied to the catchall post-processing loop. The SQL-levelshared_gated_readerclause also applies beforeLIMIT, preventing older shared personas from being starved by newer private ones on paginated catalog queries. A foreign caller POSTing{kinds:[30175],authors:[victim]}or a kindless{ids:[...]}filter to/queryreceives no unshared persona content. - NIP-98 HTTP bridge
/count—needs_shared_gate_filteringforces the per-event fallback path for any filter that can match a shared-gated kind; the fast SQLcount_events()path is not used. Both the channel-scoped and unconstrained fallback loops applyevent_visible_to_reader, preventing existence-leak via COUNT over HTTP. - FTS (NIP-50 search) and
/search— no shared-gated kind is in the relay's FTS allowlist (migration 8 indexes only kinds0, 9, 40002, 45001, 45003); no FTS result can contain an unshared event. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass.
Device sync is unaffected. The sync subscription ({kinds:[30175], authors:[self]}) reads the author's own events, which are always returned regardless of shared state.
Opting in to community sharing. Publish a NIP-33 replacement head for the persona with a ["shared", "true"] tag. Unsharing is the reverse: republish without the tag. NIP-33 replacement semantics apply (newest created_at wins).
shared is a tag, not a content field. Content bytes are hash-pinned as the NIP-01 event id and also used as the source_version for persona drift detection. A content-field toggle would look like a definition edit; a tag does not affect content bytes.
Non-goal: side-band existence oracles. Reaction, report, and event-deletion validation resolves target events by id to check that they exist. These paths intentionally accept arbitrary event references by design — they leak one bit (existence) but never content, and exploiting them requires already possessing a 64-hex event id that unshared personas never expose through any gated read path. Gating these side-band resolvers would require teaching reaction/report validation about persona read semantics with no realistic attack mitigated. If a stricter "zero existence leakage" property is required in future, it is a separate scoped task.
Security considerations
- No encryption. System prompts, model names, runtime identifiers, and all configuration are stored unencrypted. Shared persona events are readable community-wide. Operators MUST NOT store secrets in persona event content.
- System prompt protection. System prompts and
respond_to_allowlistpubkeys are sensitive. The relay's author-only-unless-shared gate ensures they are not visible to other community members unless the owner explicitly opts in by publishing a["shared", "true"]head. Shared persona events are readable community-wide; operators who need additional confidentiality should use relay-level access controls or choose not to share. - Write authority. Only the holder of
seckey_ocan publish or replace persona events. NIP-33 replacement is scoped by pubkey — no spoofing risk from other relay members. - Slug collision across pubkeys. Two different owners can publish personas with the same slug. Clients MUST always scope queries by author pubkey, not just slug.
- Metadata exposure. The
(pubkey, kind:30175, slug)triple reveals persona existence. Event timestamps reveal edit history. - No owner write authority over agents. Persona events define what an agent should be; they do not grant runtime control over a running agent. The agent consumes the persona at spawn time. Updates to the persona event do not automatically propagate to running agents.
- Sharing a team shares every member's instructions. A
kind:30178head carrying["shared","true"]exposes the team's own fields and the embedded projection of every member — including members whose ownkind:30175heads are unshared and therefore still private. Clients MUST make this explicit at the point of sharing; the relay cannot infer it.
Reference test vectors
TEST KEYS — DO NOT USE IN PRODUCTION. The keys below are pinned for reproducibility. Production code MUST source randomness from a CSPRNG.
Inputs
seckey_o = 0000000000000000000000000000000000000000000000000000000000000001
schnorr_aux = 0000000000000000000000000000000000000000000000000000000000000000
Derived
pubkey_o = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
Event 1 — create persona with all fields
// Body (exact UTF-8, no trailing whitespace):
{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}
kind = 30175
pubkey = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
created_at = 1700000000
tags = [["d", "test-agent"]]
content = {"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}
id = <derived per NIP-01: sha256([0, pubkey, created_at, kind, tags, content])>
sig = <BIP-340 Schnorr signature with aux=0x00…00>
Event 2 — minimal definition (required fields only)
A definition need not carry a prompt — pure-configuration definitions (e.g. provider/model presets) are valid:
// Body:
{"display_name":"Minimal"}
kind = 30175
pubkey = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
created_at = 1700000001
tags = [["d", "minimal"]]
content = {"display_name":"Minimal"}
id = <derived per NIP-01>
sig = <BIP-340 Schnorr signature with aux=0x00…00>
Event 3 — replacement (same slug, higher created_at)
// Updated body (system_prompt changed):
{"display_name":"Test Agent","system_prompt":"You are an updated test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta","Gamma"]}
kind = 30175
pubkey = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
created_at = 1700000002
tags = [["d", "test-agent"]]
content = {"display_name":"Test Agent","system_prompt":"You are an updated test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta","Gamma"]}
id = <derived per NIP-01>
sig = <BIP-340 Schnorr signature with aux=0x00…00>
After Event 3, the head for slug test-agent is Event 3 (greatest created_at). Event 1 is superseded.
Head selection with tiebreak
If two events share created_at = 1700000000 and slug test-agent, the head is the event with the lexicographically lowest id (hex comparison per NIP-01).
Implementation notes
Unlike NIP-AE, persona events involve no encryption, no HMAC derivation, and no conversation key. The test vectors are standard NIP-33 events with JSON content — implementations need only:
- Correct NIP-01 event-id serialization:
json.dumps([0, pubkey, created_at, kind, tags, content], separators=(",", ":"), ensure_ascii=False)over UTF-8 bytes. - BIP-340 Schnorr signing with the pinned aux value.
- JSON serialization of the content body with no trailing whitespace or BOM.