diff --git a/crates/buzz-sdk/src/agent_definitions.rs b/crates/buzz-sdk/src/agent_definitions.rs
new file mode 100644
index 00000000000..bcdedf29378
--- /dev/null
+++ b/crates/buzz-sdk/src/agent_definitions.rs
@@ -0,0 +1,464 @@
+//! Persona (kind:30175) and team (kind:30176) definition wire shapes.
+//!
+//! NIP-AP parameterized-replaceable events keyed by `(pubkey, kind, d_tag)`.
+//! This module owns the published content projections and the pure
+//! build/parse/delete helpers, so every publisher — the desktop app and the
+//! CLI — shares one definition of the wire contract. Mapping a client's own
+//! record type onto a projection stays with that client.
+//!
+//! Two things here are load-bearing and easy to "clean up" into silent data
+//! loss: [`PersonaEventContent`]'s field order, which pins content bytes and
+//! therefore event ids, and [`TeamEventContent`]'s `Option` nesting, which
+//! distinguishes "unknown, preserve local" from "explicitly emptied". Read
+//! their doc comments before changing either.
+
+use nostr::{EventBuilder, Kind, Tag};
+use serde::{Deserialize, Serialize};
+
+use crate::SdkError;
+use buzz_core::kind::{KIND_PERSONA, KIND_TEAM};
+
+/// NIP-09 deletion kind.
+const KIND_DELETE: u16 = 5;
+
+/// Maximum d-tag length in the NIP-AP slug grammar.
+pub const MAX_D_TAG_LEN: usize = 64;
+
+/// Deserialize a field so that "absent" and "present but null" stay distinct.
+///
+/// `Option>`: outer `None` = key absent, `Some(None)` = explicit
+/// `null`, `Some(Some(v))` = set. Serde collapses absent and null together
+/// without this.
+pub fn double_option<'de, T, D>(deserializer: D) -> Result >, D::Error>
+where
+ T: serde::Deserialize<'de>,
+ D: serde::Deserializer<'de>,
+{
+ serde::Deserialize::deserialize(deserializer).map(Some)
+}
+
+/// The JSON body stored in a persona event's content field.
+///
+/// Field order MUST match the NIP-AP reference vectors (`docs/nips/NIP-AP.md`
+/// content body: `display_name, system_prompt, avatar_url, runtime, model,
+/// provider, name_pool`). serde emits fields in declaration order, so this
+/// order pins the exact content bytes and therefore the NIP-01 event id — a
+/// reorder breaks cross-implementation interop.
+///
+/// Explicit opt-IN projection: `skip_serializing_if` on every optional field
+/// keeps serialized bytes stable against pre-revision publishers, which is what
+/// makes content-hash drift comparison meaningful.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PersonaEventContent {
+ /// Human-readable name shown in clients.
+ pub display_name: String,
+ /// Optional since the unified agent model (NIP-AP revision): a definition
+ /// can be pure configuration. Writers emit `Some` whenever the record has a
+ /// prompt (including the empty string) so pre-revision content bytes are
+ /// unchanged.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub system_prompt: Option,
+ /// Avatar URL, or a data URL small enough for a reader to render inline.
+ /// Desktop's inline bounds are 8 KiB for `image/svg+xml` and 256 KiB for
+ /// raster; past those, upload to media storage and publish the URL.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub avatar_url: Option,
+ /// Preferred agent harness id.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub runtime: Option,
+ /// Model identifier, interpreted relative to `runtime`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub model: Option,
+ /// Inference provider, when the runtime supports more than one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub provider: Option,
+ /// Candidate display names for instances created from this definition.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub name_pool: Vec,
+ /// Definition-level defaults copied onto instances at creation (NIP-AP
+ /// behavioral fields). Absent = defer to client defaults;
+ /// `skip_serializing_if` keeps pre-revision hashes stable.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub respond_to: Option,
+ /// Pubkeys an instance answers when `respond_to` is allowlist-scoped.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub respond_to_allowlist: Vec,
+ /// Concurrent turn limit copied onto instances at creation.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub parallelism: Option,
+}
+
+/// The JSON body stored in a team event's content field.
+///
+/// Explicit opt-IN projection of the public team fields. A team carries no
+/// secrets, but the projection is still explicit so a future record field is
+/// published only when deliberately added here. Fields describing one client's
+/// install (source directory, symlink state, builtin flag, timestamps) are
+/// intentionally absent.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TeamEventContent {
+ /// Team name.
+ pub name: String,
+ /// Optional description.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+ /// Runtime-layered instructions. `Option` is PERMANENT wire semantics (not
+ /// a transitional shim): absent = publisher predates always-publish (true
+ /// value unknown — reconcile must preserve local), `null` = explicitly
+ /// cleared, a string = set. New clients always publish this field (outer
+ /// always `Some`), using `null` for "no instructions" so that state
+ /// round-trips instead of being read back as "unknown".
+ #[serde(
+ default,
+ deserialize_with = "double_option",
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub instructions: Option>,
+ /// Persona members, by d-tag. `Option` is PERMANENT wire semantics, not a
+ /// transitional shim: `None` = publisher predates always-publish (its true
+ /// membership is unknown — reconcile must preserve local), while
+ /// `Some(vec![])` = explicitly emptied. New clients always publish
+ /// `Some(...)`, even when empty. "Cleaning up" the `Option` reintroduces
+ /// the bug where an old client's event silently wipes team membership (see
+ /// the Sietch Tabr incident).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub persona_ids: Option>,
+}
+
+/// Normalize a raw slug to the NIP-AP grammar `^[a-z0-9][a-z0-9_-]{0,63}$`
+/// (`docs/nips/NIP-AP.md:27`).
+///
+/// The relay enforces this grammar, so an un-normalized slug like
+/// `CodeReviewer` or `_ops` is signed locally but REJECTED — pending forever.
+/// The builders here deliberately do NOT call this: a d-tag is a coordinate,
+/// and rewriting it under a caller would silently move an existing definition
+/// to a new address. Callers route both the outbound publish and the inbound
+/// match key through this fn so the two cannot drift.
+///
+/// - ASCII-lowercase every char.
+/// - Map any char outside `[a-z0-9_-]` to `-`.
+/// - If the first char is not `[a-z0-9]` (a leading `_`/`-`), prepend `a`
+/// rather than trimming — trimming `_ops`→`ops` would collide with a real
+/// `ops` slug, whereas the prefix keeps distinct inputs distinct.
+/// - Truncate to [`MAX_D_TAG_LEN`] bytes.
+///
+/// Deterministic but NOT globally injective: two slugs differing only in case
+/// collapse to one d-tag. That is the correct NIP-33 behavior — same logical
+/// definition, one coordinate.
+pub fn normalize_d_tag(raw: &str) -> String {
+ let mut out: String = raw
+ .chars()
+ .map(|c| {
+ let c = c.to_ascii_lowercase();
+ if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' {
+ c
+ } else {
+ '-'
+ }
+ })
+ .collect();
+ if !out
+ .chars()
+ .next()
+ .is_some_and(|c| c.is_ascii_alphanumeric())
+ {
+ out.insert(0, 'a');
+ }
+ out.truncate(MAX_D_TAG_LEN);
+ out
+}
+
+/// Build a kind:30175 persona event from an already-projected content body.
+///
+/// `d_tag` is written verbatim, so callers must pass it through
+/// [`normalize_d_tag`] first — the relay enforces the slug grammar on persona
+/// d-tags and rejects anything else. (Team ids are the opposite; see
+/// [`build_team_event`].) `shared` adds the `["shared", "true"]` tag that marks
+/// a persona for catalog discovery. Returns an unsigned builder; the caller
+/// signs and submits.
+pub fn build_persona_event(
+ d_tag: &str,
+ content: &PersonaEventContent,
+ shared: bool,
+) -> Result {
+ let body = serde_json::to_string(content)
+ .map_err(|e| SdkError::InvalidInput(format!("failed to serialize persona content: {e}")))?;
+ let mut tags = vec![d_tag_of(d_tag)?];
+ if shared {
+ tags.push(
+ Tag::parse(["shared", "true"])
+ .map_err(|e| SdkError::InvalidTag(format!("invalid shared tag: {e}")))?,
+ );
+ }
+ Ok(EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), body).tags(tags))
+}
+
+/// Build a kind:30176 team event from an already-projected content body.
+///
+/// `d_tag` is the team's stable id, used verbatim. Do NOT pass it through
+/// [`normalize_d_tag`]: the relay only length-bounds team ids, and Buzz Desktop
+/// writes raw UUIDs and ids like `builtin-team:welcome`, so normalizing would
+/// address a different coordinate than the one Desktop published. Returns an
+/// unsigned builder; the caller signs and submits.
+pub fn build_team_event(d_tag: &str, content: &TeamEventContent) -> Result {
+ let body = serde_json::to_string(content)
+ .map_err(|e| SdkError::InvalidInput(format!("failed to serialize team content: {e}")))?;
+ Ok(EventBuilder::new(Kind::Custom(KIND_TEAM as u16), body).tags(vec![d_tag_of(d_tag)?]))
+}
+
+/// Build a NIP-09 deletion targeting a persona's kind:30175 coordinate.
+pub fn build_persona_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result {
+ build_coordinate_delete(KIND_PERSONA, d_tag, owner_pubkey_hex)
+}
+
+/// Build a NIP-09 deletion targeting a team's kind:30176 coordinate.
+pub fn build_team_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result {
+ build_coordinate_delete(KIND_TEAM, d_tag, owner_pubkey_hex)
+}
+
+/// Build a kind:5 deletion carrying a single NIP-33 coordinate `a`-tag.
+///
+/// Deliberately no `e`-tag: an `e`-tag routes the relay to the event-id
+/// deletion path, which leaves the parameterized-replaceable coordinate live.
+/// The coordinate delete removes the definition for every client and across
+/// reboots.
+fn build_coordinate_delete(
+ kind: u32,
+ d_tag: &str,
+ owner_pubkey_hex: &str,
+) -> Result {
+ let coord = format!("{kind}:{owner_pubkey_hex}:{d_tag}");
+ let tag = Tag::parse(["a", coord.as_str()])
+ .map_err(|e| SdkError::InvalidTag(format!("invalid a-tag: {e}")))?;
+ Ok(EventBuilder::new(Kind::Custom(KIND_DELETE), "").tags(vec![tag]))
+}
+
+/// Parse a kind:30175 event's content into its projection.
+pub fn persona_content_from_event(event: &nostr::Event) -> Result {
+ serde_json::from_str(event.content.as_ref())
+ .map_err(|e| SdkError::InvalidInput(format!("failed to parse persona event content: {e}")))
+}
+
+/// Parse a kind:30176 event's content into its projection.
+///
+/// Returns the projection, NOT a client record: install-specific local fields
+/// cannot be represented, so an inbound event can only overwrite the shared
+/// fields. The caller patches them onto its local record, matching on the
+/// d-tag.
+pub fn team_content_from_event(event: &nostr::Event) -> Result {
+ serde_json::from_str(event.content.as_ref())
+ .map_err(|e| SdkError::InvalidInput(format!("failed to parse team event content: {e}")))
+}
+
+/// Read an event's `d` tag value.
+pub fn event_d_tag(event: &nostr::Event) -> Option<&str> {
+ event.tags.iter().find_map(|tag| {
+ let values = tag.as_slice();
+ match (values.first().map(String::as_str), values.get(1)) {
+ (Some("d"), Some(value)) => Some(value.as_str()),
+ _ => None,
+ }
+ })
+}
+
+fn d_tag_of(d_tag: &str) -> Result {
+ Tag::parse(["d", d_tag]).map_err(|e| SdkError::InvalidTag(format!("invalid d-tag: {e}")))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use nostr::Keys;
+
+ const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
+
+ fn persona() -> PersonaEventContent {
+ PersonaEventContent {
+ display_name: "Herring".into(),
+ system_prompt: Some("You own output quality.".into()),
+ avatar_url: None,
+ runtime: Some("buzz-agent".into()),
+ model: Some("databricks-kimi-3".into()),
+ provider: None,
+ name_pool: vec!["Herring".into()],
+ respond_to: Some("owner-only".into()),
+ respond_to_allowlist: Vec::new(),
+ parallelism: Some(24),
+ }
+ }
+
+ fn team() -> TeamEventContent {
+ TeamEventContent {
+ name: "Red team".into(),
+ description: Some("Three skeptics.".into()),
+ instructions: Some(None),
+ persona_ids: Some(vec!["quinby".into(), "herring".into()]),
+ }
+ }
+
+ fn sign(builder: EventBuilder) -> nostr::Event {
+ builder.sign_with_keys(&Keys::generate()).unwrap()
+ }
+
+ #[test]
+ fn persona_event_carries_kind_d_tag_and_content() {
+ let event = sign(build_persona_event("herring", &persona(), false).unwrap());
+ assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA);
+ assert_eq!(event_d_tag(&event), Some("herring"));
+ assert_eq!(persona_content_from_event(&event).unwrap(), persona());
+ }
+
+ /// A d-tag is a coordinate: normalizing inside the builder would move an
+ /// existing definition to a new address behind the caller's back.
+ #[test]
+ fn builders_use_the_d_tag_verbatim() {
+ let persona_event = sign(build_persona_event("CodeReviewer", &persona(), false).unwrap());
+ assert_eq!(event_d_tag(&persona_event), Some("CodeReviewer"));
+ let team_event = sign(build_team_event("Sietch-Tabr", &team()).unwrap());
+ assert_eq!(event_d_tag(&team_event), Some("Sietch-Tabr"));
+ }
+
+ #[test]
+ fn shared_flag_controls_the_shared_tag() {
+ for shared in [false, true] {
+ let event = sign(build_persona_event("herring", &persona(), shared).unwrap());
+ let has = event
+ .tags
+ .iter()
+ .any(|t| t.as_slice().first().map(String::as_str) == Some("shared"));
+ assert_eq!(has, shared, "shared={shared}");
+ }
+ }
+
+ /// Field order pins the content bytes and therefore the event id; a reorder
+ /// breaks cross-implementation interop and every stored content hash.
+ #[test]
+ fn persona_content_field_order_matches_nip_ap() {
+ // Byte-exact, every field populated: a substring-order check would miss
+ // a field appearing in the wrong place among the ones it doesn't name.
+ let full = PersonaEventContent {
+ display_name: "Herring".into(),
+ system_prompt: Some("Ask the annoying question.".into()),
+ avatar_url: Some("https://example.invalid/h.png".into()),
+ runtime: Some("claude".into()),
+ model: Some("claude-opus-5".into()),
+ provider: Some("anthropic".into()),
+ name_pool: vec!["Herring".into()],
+ respond_to: Some("allowlist".into()),
+ respond_to_allowlist: vec!["ab".repeat(32)],
+ parallelism: Some(2),
+ };
+ let allowlisted = "ab".repeat(32);
+ let expected = concat!(
+ r#"{"display_name":"Herring","system_prompt":"Ask the annoying question.","#,
+ r#""avatar_url":"https://example.invalid/h.png","runtime":"claude","#,
+ r#""model":"claude-opus-5","provider":"anthropic","name_pool":["Herring"],"#,
+ r#""respond_to":"allowlist","respond_to_allowlist":["{ALLOWLISTED}"],"parallelism":2}"#,
+ )
+ .replace("{ALLOWLISTED}", &allowlisted);
+ assert_eq!(serde_json::to_string(&full).unwrap(), expected);
+
+ // Absent optionals stay absent so pre-revision bytes are unchanged.
+ let json = serde_json::to_string(&persona()).unwrap();
+ assert!(!json.contains("avatar_url"), "{json}");
+ assert!(!json.contains("provider"), "{json}");
+ assert!(!json.contains("respond_to_allowlist"), "{json}");
+ }
+
+ #[test]
+ fn team_event_round_trips_through_content() {
+ let event = sign(build_team_event("red-team", &team()).unwrap());
+ assert_eq!(event.kind.as_u16() as u32, KIND_TEAM);
+ assert_eq!(event_d_tag(&event), Some("red-team"));
+ assert_eq!(team_content_from_event(&event).unwrap(), team());
+ }
+
+ /// Absent vs explicitly-null must stay distinguishable: absent means
+ /// "publisher predates always-publish, preserve local", null means
+ /// "cleared". Collapsing them wipes team state.
+ #[test]
+ fn absent_and_null_instructions_are_distinct() {
+ let absent: TeamEventContent =
+ serde_json::from_str(r#"{"name":"T","persona_ids":[]}"#).unwrap();
+ assert_eq!(absent.instructions, None);
+
+ let cleared: TeamEventContent =
+ serde_json::from_str(r#"{"name":"T","instructions":null,"persona_ids":[]}"#).unwrap();
+ assert_eq!(cleared.instructions, Some(None));
+
+ let set: TeamEventContent =
+ serde_json::from_str(r#"{"name":"T","instructions":"go","persona_ids":[]}"#).unwrap();
+ assert_eq!(set.instructions, Some(Some("go".into())));
+
+ // A cleared value must serialize as null, not vanish.
+ let json = serde_json::to_string(&cleared).unwrap();
+ assert!(json.contains(r#""instructions":null"#), "{json}");
+ }
+
+ /// Absent membership means "unknown, preserve local"; an empty array means
+ /// "deliberately emptied". These must not serialize identically — the
+ /// absent shape is the Sietch Tabr wipe event.
+ #[test]
+ fn absent_and_empty_persona_ids_are_distinct() {
+ let absent: TeamEventContent = serde_json::from_str(r#"{"name":"T"}"#).unwrap();
+ assert_eq!(absent.persona_ids, None);
+ assert!(!serde_json::to_string(&absent)
+ .unwrap()
+ .contains("persona_ids"));
+
+ let emptied = TeamEventContent {
+ persona_ids: Some(Vec::new()),
+ ..absent
+ };
+ assert!(serde_json::to_string(&emptied)
+ .unwrap()
+ .contains(r#""persona_ids":[]"#));
+ }
+
+ #[test]
+ fn d_tag_normalization_follows_the_relay_grammar() {
+ assert_eq!(normalize_d_tag("CodeReviewer"), "codereviewer");
+ assert_eq!(normalize_d_tag("_ops"), "a_ops", "leading _ is prefixed");
+ assert_eq!(normalize_d_tag("-x"), "a-x", "leading - is prefixed");
+ assert_eq!(normalize_d_tag("a b.c"), "a-b-c");
+ assert_eq!(normalize_d_tag(&"z".repeat(80)).len(), MAX_D_TAG_LEN);
+ assert_eq!(normalize_d_tag(""), "a", "empty stays grammar-valid");
+ // Already-valid slugs pass through untouched.
+ assert_eq!(normalize_d_tag("red-team"), "red-team");
+ }
+
+ #[test]
+ fn delete_uses_one_coordinate_tag_and_no_e_tag() {
+ for (builder, kind, d_tag) in [
+ (
+ build_persona_delete("herring", OWNER).unwrap(),
+ KIND_PERSONA,
+ "herring",
+ ),
+ (
+ build_team_delete("red-team", OWNER).unwrap(),
+ KIND_TEAM,
+ "red-team",
+ ),
+ ] {
+ let event = sign(builder);
+ assert_eq!(event.kind, Kind::Custom(KIND_DELETE));
+
+ let a_tags: Vec<&[String]> = event
+ .tags
+ .iter()
+ .map(|t| t.as_slice())
+ .filter(|v| v.first().map(String::as_str) == Some("a"))
+ .collect();
+ assert_eq!(a_tags.len(), 1);
+ assert_eq!(a_tags[0][1], format!("{kind}:{OWNER}:{d_tag}"));
+
+ // An e-tag would route to the event-id deletion path and leave the
+ // replaceable coordinate live.
+ assert!(event
+ .tags
+ .iter()
+ .all(|t| t.as_slice().first().map(String::as_str) != Some("e")));
+ }
+ }
+}
diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs
index 4ee0cd4c882..0737b0c8a1a 100644
--- a/crates/buzz-sdk/src/lib.rs
+++ b/crates/buzz-sdk/src/lib.rs
@@ -12,6 +12,7 @@
//! The caller signs with their own keys: `builder.sign_with_keys(&keys)?`.
//! No keys are held here. No network calls are made.
+pub mod agent_definitions;
pub mod builders;
pub mod mentions;
pub mod nip_oa;
diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs
index 7a3ce35b036..6f71cb5b86d 100644
--- a/desktop/src-tauri/src/managed_agents/persona_events.rs
+++ b/desktop/src-tauri/src/managed_agents/persona_events.rs
@@ -5,50 +5,18 @@
use std::collections::BTreeMap;
-use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA};
-use nostr::{EventBuilder, Kind, Tag};
-use serde::{Deserialize, Serialize};
+use buzz_core_pkg::kind::event_is_shared;
+use buzz_sdk_pkg::agent_definitions::normalize_d_tag;
+use nostr::EventBuilder;
use super::{AgentDefinition, ManagedAgentRecord};
use crate::app_state::AppState;
-/// The JSON body stored in a persona event's content field.
-///
-/// Field order MUST match the NIP-AP reference vectors (`docs/nips/NIP-AP.md`
-/// content body: `display_name, system_prompt, avatar_url, runtime, model,
-/// provider, name_pool`). serde emits fields in declaration order, so this
-/// order pins the exact content bytes and therefore the NIP-01 event id — a
-/// reorder here breaks cross-implementation interop. Guarded by
+/// The published persona wire shape, owned by the SDK so the CLI publishes the
+/// identical body — including the field order that pins content bytes and
+/// therefore `persona_content_hash`. Guarded by
/// `content_matches_nip_ap_vector`.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PersonaEventContent {
- pub display_name: String,
- /// Optional since the unified agent model (NIP-AP revision): a definition
- /// can be pure configuration. Writers emit `Some` whenever the record has
- /// a prompt (including the empty string) so pre-revision content bytes —
- /// and therefore `persona_content_hash` — are unchanged.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub system_prompt: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub avatar_url: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub runtime: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub model: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub provider: Option,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub name_pool: Vec,
- /// Definition-level defaults copied onto instances at creation
- /// (NIP-AP behavioral fields). Absent = defer to client defaults;
- /// `skip_serializing_if` keeps pre-revision hashes stable.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub respond_to: Option,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub respond_to_allowlist: Vec,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub parallelism: Option,
-}
+pub use buzz_sdk_pkg::agent_definitions::PersonaEventContent;
/// Derive the d-tag (persona slug) from a `AgentDefinition`.
///
@@ -70,45 +38,6 @@ pub fn persona_d_tag(record: &AgentDefinition) -> String {
normalize_d_tag(raw)
}
-/// Normalize a raw slug to the NIP-AP grammar `^[a-z0-9][a-z0-9_-]{0,63}$`.
-///
-/// - ASCII-lowercase every char (pack slugs are `[a-zA-Z0-9_-]+`, so this is
-/// the only transform uppercase slugs need).
-/// - Map any char outside `[a-z0-9_-]` to `-` (defensive; pack slugs never
-/// contain such chars, but `id` fallbacks and future inputs might).
-/// - If the first char is not `[a-z0-9]` (i.e. a leading `_`/`-`), prepend `a`
-/// rather than trimming — trimming `_ops`→`ops` would collide with a real
-/// `ops` pack, whereas the prefix keeps distinct inputs distinct.
-/// - Truncate to 64 bytes (the grammar's max).
-///
-/// The transform is deterministic. It is NOT globally injective (`A-b` and
-/// `a_b` both contain only safe chars and stay distinct, but two slugs
-/// differing only in case — e.g. `Ops` and `ops` — collapse to the same
-/// d-tag). That case-fold collision is inherent to the lowercase relay grammar
-/// and is the correct NIP-33 behavior: same logical persona, one coordinate.
-fn normalize_d_tag(raw: &str) -> String {
- let mut out: String = raw
- .chars()
- .map(|c| {
- let c = c.to_ascii_lowercase();
- if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' {
- c
- } else {
- '-'
- }
- })
- .collect();
- if !out
- .chars()
- .next()
- .is_some_and(|c| c.is_ascii_alphanumeric())
- {
- out.insert(0, 'a');
- }
- out.truncate(64);
- out
-}
-
/// Compute the NIP-AP monotonic `created_at` for a write (`docs/nips/NIP-AP.md:117`
/// step 3): `max(now, T + 1)` where `T` is the retained head's `created_at`
/// (or 0 when no head exists).
@@ -121,9 +50,12 @@ fn normalize_d_tag(raw: &str) -> String {
/// Bumping past the head guarantees a fresh write always supersedes regardless
/// of clock skew.
pub fn monotonic_created_at(prior_head_created_at: Option) -> nostr::Timestamp {
- let now = nostr::Timestamp::now().as_secs() as i64;
- let floor = prior_head_created_at.map_or(0, |t| t + 1);
- nostr::Timestamp::from(now.max(floor) as u64)
+ // Arithmetic lives in buzz-core (pure, `now` injected, unit-tested there);
+ // this wrapper only supplies the clock.
+ nostr::Timestamp::from(buzz_core_pkg::engram::monotonic_created_at(
+ nostr::Timestamp::now().as_secs(),
+ prior_head_created_at.map(|t| t.max(0) as u64),
+ ))
}
/// Build a kind:30175 event from a `AgentDefinition`.
@@ -132,19 +64,12 @@ pub fn monotonic_created_at(prior_head_created_at: Option) -> nostr::Timest
pub fn build_persona_event(record: &AgentDefinition) -> Result {
// Single projection point — persona_event_content owns the field mapping
// (and the hash-stability rules that come with it).
- let content = persona_event_content(record);
-
- let content_json = serde_json::to_string(&content)
- .map_err(|e| format!("failed to serialize persona content: {e}"))?;
-
- let d_tag = persona_d_tag(record);
- let mut tags =
- vec![Tag::parse(["d", d_tag.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?];
- if record.shared {
- tags.push(Tag::parse(["shared", "true"]).map_err(|e| format!("invalid shared tag: {e}"))?);
- }
-
- Ok(EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), content_json).tags(tags))
+ buzz_sdk_pkg::agent_definitions::build_persona_event(
+ &persona_d_tag(record),
+ &persona_event_content(record),
+ record.shared,
+ )
+ .map_err(|e| e.to_string())
}
/// Build a NIP-09 deletion (kind:5) targeting a persona's kind:30175 event.
@@ -154,30 +79,20 @@ pub fn build_persona_event(record: &AgentDefinition) -> Result Result {
- let coord = format!("{KIND_PERSONA}:{owner_pubkey_hex}:{d_tag}");
- let tag = Tag::parse(["a", coord.as_str()]).map_err(|e| format!("invalid a-tag: {e}"))?;
- Ok(EventBuilder::new(Kind::Custom(5), "").tags(vec![tag]))
+ buzz_sdk_pkg::agent_definitions::build_persona_delete(d_tag, owner_pubkey_hex)
+ .map_err(|e| e.to_string())
}
/// Parse a kind:30175 event back into a `AgentDefinition`.
///
/// The event's d-tag becomes the persona ID and slug.
pub fn persona_from_event(event: &nostr::Event) -> Result {
- let d_tag = event
- .tags
- .iter()
- .find_map(|tag| {
- let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
- if values.first() == Some(&"d") {
- values.get(1).map(|s| s.to_string())
- } else {
- None
- }
- })
- .ok_or("persona event missing d-tag")?;
+ let d_tag = buzz_sdk_pkg::agent_definitions::event_d_tag(event)
+ .ok_or("persona event missing d-tag")?
+ .to_string();
- let content: PersonaEventContent = serde_json::from_str(event.content.as_ref())
- .map_err(|e| format!("failed to parse persona event content: {e}"))?;
+ let content = buzz_sdk_pkg::agent_definitions::persona_content_from_event(event)
+ .map_err(|e| e.to_string())?;
let created_at = event.created_at.to_human_datetime();
diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs
index 682fbef62fa..eb53f64833c 100644
--- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs
@@ -1,5 +1,7 @@
use super::*;
use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo};
+use buzz_core_pkg::kind::KIND_PERSONA;
+use nostr::{Kind, Tag};
/// A linked instance record with no persona-derived fields set yet — the
/// state right after creation, before any snapshot apply.
diff --git a/desktop/src-tauri/src/managed_agents/team_events.rs b/desktop/src-tauri/src/managed_agents/team_events.rs
index 64861c0dec6..f1d59962418 100644
--- a/desktop/src-tauri/src/managed_agents/team_events.rs
+++ b/desktop/src-tauri/src/managed_agents/team_events.rs
@@ -6,46 +6,14 @@
//! flush loop publish them — this module only owns the kind-specific
//! projection, build, and tombstone.
-use buzz_core_pkg::kind::KIND_TEAM;
-use nostr::{EventBuilder, Kind, Tag};
-use serde::{Deserialize, Serialize};
+use nostr::EventBuilder;
use super::TeamRecord;
-/// The JSON body stored in a team event's content field.
-///
-/// Explicit opt-IN projection of the public team fields. A team carries no
-/// secrets, but the projection is still explicit so a future `TeamRecord`
-/// field is published only when deliberately added here. Local-only fields
-/// (`source_dir`, `is_symlink`, `is_builtin`, timestamps) are intentionally
-/// omitted — they describe this client's install, not the shared team.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct TeamEventContent {
- pub name: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub description: Option,
- /// Runtime-layered instructions. `Option` is PERMANENT wire semantics
- /// (not a transitional shim): absent = publisher predates always-publish
- /// (true value unknown — reconcile must preserve local), `null` =
- /// explicitly cleared, a string = set. New clients always publish this
- /// field (outer always `Some`), using `null` for "no instructions" so
- /// that state round-trips instead of being read back as "unknown".
- #[serde(
- default,
- deserialize_with = "crate::util::double_option",
- skip_serializing_if = "Option::is_none"
- )]
- pub instructions: Option>,
- /// Pack persona members. `Option` is PERMANENT wire semantics, not a
- /// transitional shim: `None` = publisher predates always-publish (its
- /// true membership is unknown — reconcile must preserve local), while
- /// `Some(vec![])` = explicitly emptied. New clients always publish
- /// `Some(...)`, even when empty. "Cleaning up" the Option later
- /// reintroduces the bug where an old client's event silently wipes team
- /// membership (see the Sietch Tabr incident).
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub persona_ids: Option>,
-}
+/// The published team wire shape, owned by the SDK so the CLI publishes the
+/// identical body — including the `Option` nesting that separates "publisher
+/// predates always-publish, preserve local" from "explicitly emptied".
+pub use buzz_sdk_pkg::agent_definitions::TeamEventContent;
/// Project a `TeamRecord` onto the content fields published in team events.
/// Centralizes the field mapping so a new published field is added in exactly
@@ -66,11 +34,8 @@ pub fn team_event_content(record: &TeamRecord) -> TeamEventContent {
///
/// Returns an unsigned `EventBuilder` — the caller signs and submits.
pub fn build_team_event(record: &TeamRecord) -> Result {
- let content = serde_json::to_string(&team_event_content(record))
- .map_err(|e| format!("failed to serialize team content: {e}"))?;
- let tags =
- vec![Tag::parse(["d", record.id.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?];
- Ok(EventBuilder::new(Kind::Custom(KIND_TEAM as u16), content).tags(tags))
+ buzz_sdk_pkg::agent_definitions::build_team_event(&record.id, &team_event_content(record))
+ .map_err(|e| e.to_string())
}
/// Parse a kind:30176 event's content into the projection — the inbound
@@ -83,8 +48,7 @@ pub fn build_team_event(record: &TeamRecord) -> Result {
/// patches them onto the local record (see `apply_inbound_team`), matching on
/// the d-tag (the team's id).
pub fn team_content_from_event(event: &nostr::Event) -> Result {
- serde_json::from_str(event.content.as_ref())
- .map_err(|e| format!("failed to parse team event content: {e}"))
+ buzz_sdk_pkg::agent_definitions::team_content_from_event(event).map_err(|e| e.to_string())
}
/// Build a NIP-09 deletion (kind:5) targeting a team's kind:30176 event.
@@ -94,14 +58,15 @@ pub fn team_content_from_event(event: &nostr::Event) -> Result Result {
- let coord = format!("{KIND_TEAM}:{owner_pubkey_hex}:{d_tag}");
- let tag = Tag::parse(["a", coord.as_str()]).map_err(|e| format!("invalid a-tag: {e}"))?;
- Ok(EventBuilder::new(Kind::Custom(5), "").tags(vec![tag]))
+ buzz_sdk_pkg::agent_definitions::build_team_delete(d_tag, owner_pubkey_hex)
+ .map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
+ use buzz_core_pkg::kind::KIND_TEAM;
+ use nostr::Kind;
use std::path::PathBuf;
fn sample_team() -> TeamRecord {
diff --git a/desktop/src-tauri/src/util.rs b/desktop/src-tauri/src/util.rs
index 7fa4dc6768e..0c2e72e265a 100644
--- a/desktop/src-tauri/src/util.rs
+++ b/desktop/src-tauri/src/util.rs
@@ -11,13 +11,10 @@ pub fn now_iso() -> String {
/// "clear this field" indistinguishable from "leave it unchanged". Paired with
/// `#[serde(default)]`, this yields the tri-state needed for nullable patches:
/// absent → `None`, `null` → `Some(None)`, value → `Some(Some(value))`.
-pub fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error>
-where
- T: serde::Deserialize<'de>,
- D: serde::Deserializer<'de>,
-{
- serde::Deserialize::deserialize(deserializer).map(Some)
-}
+///
+/// Re-exported from the SDK: the persona/team wire shapes live there and depend
+/// on this exact tri-state, so both sides must share one implementation.
+pub use buzz_sdk_pkg::agent_definitions::double_option;
/// Turn a human-readable name into a filesystem-safe slug.
///