Skip to content

docs(nips): NIP-AE — Agent Engrams#575

Merged
tlongwell-block merged 4 commits into
mainfrom
dawn/nip-ae-agent-engrams
May 14, 2026
Merged

docs(nips): NIP-AE — Agent Engrams#575
tlongwell-block merged 4 commits into
mainfrom
dawn/nip-ae-agent-engrams

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented May 14, 2026

What this is

A draft NIP — NIP-AE, Agent Engrams — for AI agents to keep persistent, structured memory on Nostr. The whole spec is one file: docs/nips/NIP-AE.md. No code changes.

The convention is small on purpose: a dedicated addressable kind plus a few mechanical rules. No relay changes, no new crypto, no negotiation. Two implementations following the spec from text alone converge on bit-identical events.

The model in one paragraph

Memory is scoped to a (pubkey_a, pubkey_o) pair — one agent, one owner. The agent is the sole signer; the agent and owner share a NIP-44 conversation key, so both can decrypt every record. The owner is a first-class reader by construction, not by permission. Two record types: core (one per pair, holds identity/rules/goals) and memory (zero or more, one logical entry each). Both ride a kind:30174 envelope, differing only by the slug at which they are addressed. Slugs are never visible on the wire — the d tag is HMAC-SHA256(K_c, "agent-memory/v1/d-tag" || 0x00 || slug). Tombstones are an in-band write with value: null. Cross-record references use wiki-link syntax [[mem/foo]] extracted by literal substring; a reachability graph rooted at core.profile (non-normative) gives implementations a deterministic answer to "which memories are referenced from the agent's identity surface," and anything outside it is an orphan surfaced for review.

Why a dedicated kind (30174)?

Earlier drafts rode kind:30078 (NIP-78 application-specific data) and demultiplexed via the validity gate. That worked — the spec was honest about it — but it shoved a coordination concern (sharing an address space with arbitrary other apps on the same author) into the protocol when we didn't have to.

A dedicated kind:

  • Isolates this NIP's address space from any other app the agent's pubkey might also write.
  • Lets observers and unknown-kind viewers identify these events from the kind alone, without attempting NIP-44 decryption as a namespace demultiplexer.
  • Honestly claims a slot in the NIP-01 addressable range rather than borrowing one.

Choice of 30174 is mnemonic: 30 || 0xAE where AE is Agent Engrams — the same hex encoding the NIP indicator uses. Verified unused against the kinds table in nostr-protocol/nips and unmentioned in the broader repo.

Design discipline (what the spec actually pins)

The structural moves are what makes it small:

  • HMAC-SHA256 d-tags under K_c, domain-separated by a fixed version-tagged prefix. Reveals nothing to passive observers; deterministic for both parties.
  • Validity is one atomic gate of five rules (kind/pubkey/tags, signature, decrypt + JSON parse that rejects duplicate member names, slug↔d re-derivation, body shape). Applied before head selection, identical for read/write-verify/list. One predicate everywhere.
  • Monotonic created_at per slug (max(now, T_head + 1)) defeats NIP-01's same-second tiebreak under NIP-44 random nonces. A head whose created_at is implausibly far ahead of wall-clock time SHOULD be treated as clock-poisoned and surfaced as a conflict rather than published; the threshold is left to implementations.
  • The walk is the source of truth. Listing is defined as a result-shape — the set of head tuples produced by querying every configured relay, unioning, validating, and reducing — not a mechanism. Clients MAY keep an out-of-band {slug → {event_id, created_at}} cache to accelerate listing, but it is implementation-local and outside the wire format; an in-band index field was dropped after review because it created a concurrent-writer conflict surface for no protocol-level benefit.
  • Discovery specified by result, not mechanism. The same shape-not-mechanism rule means a future relay-maintained materialized view over public d tags can slot in without a wire change.
  • Listing is best-effort. Nostr has no protocol-level pagination, so any complete-set claim is provisional. Clients SHOULD surface per-relay truncation signals (limit-reached, event counts) where available so callers can detect under-reporting.
  • Reachability via literal [[<slug>]] substring extraction — clean machine-extractable signal without forcing the agent to maintain a parallel structured-links field. Marked non-normative; conformance doesn't require honoring it. A companion NIP can make it normative.
  • Configured relays = filtered NIP-65 write relays of pubkey_a (write or no-marker entries, valid ws:///wss:// URLs), with an out-of-band fallback when the published list is absent or yields zero usable entries. URL canonicalization is for comparison only (lowercase scheme/host, strip default port, strip empty-path trailing slash, dedupe so two parties compute the same set); connections SHOULD be made to the advertised form so relay-side path/host disambiguation is preserved.
  • p tag carries its usual NIP-01 meaning. Generic NIP-65-aware clients may fan a record out to the owner's read relays; this NIP neither requires nor forbids that. Authoritative discovery is always from the agent's configured relays so owners and observers converge on the same head set.
  • Write verification (recommended, not required). Implementations SHOULD recompute the head after a relay OK (optionally with a short propagation delay) and surface a conflict if the published event is not the head. Best-effort by design: writes arriving after the recompute window remain subject to the eventual-consistency semantics. Fixed-window and "MUST NOT silently retry" policies were softened to SHOULDs.

Reference test vectors (embedded, self-verifying)

The spec embeds deterministic values derived from pinned seckey_a/seckey_o/Schnorr aux/NIP-44 nonces. An implementer can re-derive every event id, signature, ciphertext, and d-value from the spec's own inputs and byte-diff against the spec's own outputs.

  • Reuses NIP-44's published test scalars (sec1=…01, sec2=…02) so K_c cross-checks for free against nip44.vectors.json line 281 (c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d).
  • Reuses BIP-340's test vector 0 sig prefix (e907831f80…) as an external anchor for the aux-interpretation gotcha.
  • Four events exercise: single-segment slug, multi-segment slug, tombstone-supersedes-prior (same d, greater created_at), and a core event whose profile contains wiki-link references to both memory slugs — exercising wiki-link extraction from both profile and value, plus a dangling reference to a tombstoned slug.

Three documented implementation gotchas — places where independent re-derivations are most likely to diverge silently:

  1. NIP-44 ECDH IKM is the raw shared_x, not its SHA-256 (some bindings' default ecdh() hashes it).
  2. BIP-340 aux = 0x00…00 is not "aux omitted" — it must be passed through the BIP0340/aux tagged hash and XOR'd with the secret. Some libsecp256k1 wrappers default sign_custom to NULL extraparams and silently skip the XOR.
  3. NIP-01 event-id serialization needs ensure_ascii=False — the moment a body contains a non-ASCII character, \u escapes diverge.

The vectors were originally co-produced and cross-validated against an independent implementation by Sami; outputs matched byte-for-byte across two crypto stacks. After moving to kind 30174 the event ids and signatures changed (kind is in the id pre-image); the pubkeys, conversation key, d-tags, and ciphertexts 1–3 are unchanged from those earlier verified vectors. Event 4's body changed again when core.index was dropped, so its ciphertext, id, and signature were regenerated; all four sigs re-verify under Schnorr.

What's explicitly out of scope

Provenance / trust levels, attention or working sets, structured links, owner-to-agent directives, admission control. Bodies use a strict-required-fields / permissive-unknown-fields rule so companion NIPs can add these later without breaking v1 readers.

Acknowledged caveats (in the spec, not just the PR)

  • No forward secrecy. Static ECDH; compromise of either party's secret key exposes the full history. Trade-off accepted for symmetric read access.
  • Compromised agent key = silent rewrite and full read. Holders of seckey_a can rewrite or tombstone any record, and can re-derive K_c against every known owner pubkey to decrypt all past and future records for those pairs. Replaceable events leave no protocol-level trace of overwrites on compliant relays. Archival relays may show that rewrites occurred but cannot identify which version is authoritative; the NIP defines no version-chaining mechanism.
  • Metadata leak. The triple (pubkey_a, kind:30174, p=pubkey_o) reveals that an account uses agent memory and identifies its owner. Pseudonymous, not anonymous.
  • No owner write authority. The owner can read everything but cannot author records. Any control plane is out of band.
  • Memory poisoning is unsolved at the spec layer. Encryption protects confidentiality, not the truthfulness of what the agent decides to remember. Admission control is the implementer's problem.

Risk / scope of this PR

Documentation only. One file added to docs/nips/. No code paths touched.

How it got here

This NIP went through a long iteration in the #sprout-memory channel. Highlights of what the design walked back from earlier sketches:

  • Dropped a parallel owner control plane (four verbs + polling + replay protection). The owner influences memory by talking to the agent, not by signing.
  • Dropped append-only knowledge / ULID / supersession-link machinery in favor of two replaceable types. Audit trails are a deployment concern, not a protocol one.
  • Dropped private-from-owner memory. An agent that can cryptographically hide work from its owner is misaligned by construction; we don't build that affordance.
  • Dropped structured links arrays in favor of literal [[<slug>]] substring references — the agent already thinks in prose, so the references live in prose.
  • Dropped core.index (was in earlier drafts of this PR). The walk over configured relays was already authoritative; an in-band cache added a concurrent-writer conflict surface for no protocol-level benefit. Clients that want one keep it locally.

Reviewed cross-NIP against NIP-01 / 09 / 44 / 65 / 78 in two parallel passes, then again head-to-head against ten upstream comparators (01, 11, 17, 22, 23, 44, 51, 65, 78, 90). Beats nine; ties NIP-44 in original orientation and loses to NIP-44 in spec-identity mode — a structurally honest result (NIP-44 is a byte-level crypto primitive with full algorithmic pinning; a coordination-layer NIP can't match its correctness density).

After PR open, five rounds of codex review against the upstream NIPs produced the structural simplifications committed as 93d2c46: dropping core.index, decoupling the p tag from a NIP-65 carve-out, softening write-verify and clock-poison from MUSTs to SHOULDs, splitting URL canonicalization (compare-only) from URL connect (advertised form), and marking the reachability graph non-normative. Codex never crossed 8/10 on the scored rubric; each pass pivoted to a new "major," which read as the rubric flooring drafts at 8 against "merge upstream as-is" rather than a genuine remaining defect. Shipped at 8.

tlongwell-block and others added 3 commits May 13, 2026 22:47
Defines a convention for AI agents to store persistent encrypted memory
on Nostr as addressable kind:30078 events. The agent owns and signs every
record; the owner can always decrypt (NIP-44 conversation key is symmetric)
but cannot write.

Highlights:
- Slug-derived opaque d-tags via HMAC(K_c, ...) — no slug leakage in tags.
- Two record types (core, memory) sharing one envelope, demuxed by body shape.
- Monotonic created_at write rule defeats same-second NIP-01 tiebreaks.
- Wiki-link [[slug]] reachability graph; orphan-aware listing.
- Test vectors pinned end-to-end (keys, K_c, d-tags, ciphertexts, ids, sigs)
  with implementation gotchas (raw shared_x ECDH, BIP-340 aux=zeros, NIP-01
  serialization).

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Codex head-to-head review against NIP-44 flagged silent-divergence
surface in two places; both are real and worth fixing:

1. Configured relays: ordered precedence (NIP-65 first, OOB fallback)
   plus an explicit URL canonicalization rule (case-fold scheme/host,
   strip default port, strip trailing slash on empty path) so two parties
   compute the same set from the same input.

2. Write verification: pin the gating event (first relay OK) instead of
   a free-form implementation timeout, and add a one-second propagation
   default to absorb relay-set skew. Conflict surfacing semantics are
   unchanged.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Earlier NIP-AE drafts rode on kind:30078 (NIP-78 application-specific data).
A dedicated addressable kind cleans this up:

- One less coordination concern (no need for the validity-gate-as-namespace-
  demultiplexer wording; the kind itself segregates).
- Predictable address space for relay implementers.
- Honest claim on a slot in the NIP-01 addressable range (30000-39999).

Choice of 30174 is mnemonic: 30 || 0xAE where AE is Agent Engrams — the
same hex encoding the NIP indicator already uses.

All test vectors regenerated under the new kind. The conversation key,
d-tag derivations, and ciphertexts for events 1-3 are unchanged (none of
those derivations include kind). Event 4's body embeds event 2's id, so
event 4's ciphertext also moves; event ids and signatures for all four
events change because kind is in the NIP-01 id pre-image.

Self-verification preserved: all four event ids re-derive from the spec's
own inputs and pubkey_a/pubkey_o/K_c/d-values are unchanged from prior
revisions.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
@tlongwell-block tlongwell-block force-pushed the dawn/nip-ae-agent-engrams branch from 3b6dd1e to 85fcb6a Compare May 14, 2026 02:49
Five passes of codex review against the upstream NIPs surfaced a mix of
real structural issues and rubric pressure for further simplification.
Folding the substantive ones in one commit:

- Drop `core.index` entirely. The walk in *Listing* is authoritative and
  was already the recovery path for any divergence; making the cache
  in-band created a conflict surface (concurrent writers can race the
  index update separately from the memory write) for no protocol-level
  benefit. Clients MAY keep an out-of-band `{slug -> {event_id,
  created_at}}` cache; that's now noted as implementation-local.
  Drops `index` from the core body, the *Reindex* section, and the
  "core write follows every memory write" guidance. Event 4's body
  and therefore its content, id, and sig are regenerated.

- `p` tag stops fighting NIP-65. Previous text told clients NOT to
  fan out memory events to the owner's read relays. The carve-out is
  worse than the deviation: now the spec just notes that authoritative
  discovery is from the agent's configured relays, and copies arriving
  via NIP-65 fanout on owner read relays are a redundant cache, not a
  separate channel. Metadata leak is already in Security.

- URL canonicalization is for comparison only. Implementations connect
  to the advertised form so relay-side path/host disambiguation is
  preserved.

- Write-verify softened from MUST to SHOULD. The one-second propagation
  window and "MUST NOT silently retry" are policy, not protocol;
  surfaced as best-effort with conflict semantics still defined.

- Clock-poison check softened: keep the monotonicity rule, drop the
  fixed 600s threshold; treat clock-poisoning as SHOULD-detect with
  implementation-chosen threshold.

- `core` no longer SHOULD-updates on every memory write (consequence
  of dropping `index`).

- NIP-09 paragraph: explicitly states only `pubkey_a` may author
  deletions (per NIP-09 author constraint), and "deletes all versions"
  softened to "requests deletion of" (relay policy, not protocol
  guarantee).

- Dedicated kind justification added: namespace isolation from any
  other app sharing the agent's pubkey, plus identification by kind
  alone without NIP-44 decryption as a demultiplexer.

- *Listing* explicit about best-effort: Nostr has no protocol-level
  pagination, so any complete-set claim is provisional; clients
  SHOULD surface per-relay limit signals where available.

- Relay-set migration: one-sentence MAY note that agents republish
  current heads to a new set before decommissioning a relay they are
  leaving; this NIP defines no automatic migration mechanism.

- `nsec_a`/`nsec_o` renamed to `seckey_a`/`seckey_o` in vectors and
  Security (nsec is the NIP-19 bech32 form; the inputs are raw hex
  scalars).

- NIP-31 `alt` tag noted as MAY (non-leaking summary for unknown-kind
  viewers).

- Security: agent-key compromise note now mentions that the holder
  can also re-derive K_c and break confidentiality, not just integrity.

- References-and-reachability marked non-normative; orphan policy
  conformance-neutral.

- NIP-01 retention prose aligned with upstream's MUST/SHOULD wording
  ("relays SHOULD return only the latest; some may retain older
  versions").

Test vectors: events 1-3 are byte-identical to the prior revision
(none of those derivations include the dropped fields). Event 4's
body shrinks, so its NIP-44 content, sha256, id, and Schnorr signature
move. All four ids self-derive and all four sigs verify under
schnorr-verify (script: vectors-30174-noindex.py).

NIP file is 251 lines, well under the 300-line ceiling.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
@tlongwell-block tlongwell-block merged commit bda98da into main May 14, 2026
15 checks passed
@tlongwell-block tlongwell-block deleted the dawn/nip-ae-agent-engrams branch May 14, 2026 16:10
tlongwell-block added a commit that referenced this pull request May 14, 2026
* origin/main:
  docs(nips): NIP-AE — Agent Engrams (#575)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
tlongwell-block added a commit that referenced this pull request May 14, 2026
Pulls in 8 commits from origin/main:
- 1858e98 fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
- 9e76a08 fix(desktop): refine header scaling and shadow (#573)
- b74ec95 fix(desktop): keep day dividers below header (#574)
- aad564b Move agent activity below composer (#579)
- bda98da docs(nips): NIP-AE — Agent Engrams (#575)
- 1b87a09 refactor: extract shared @mention resolver into sprout-sdk (#580)
- 2ee7356 fix: add default-run to sprout-relay so `cargo run -p sprout-relay` works (#577)
- f0549b5 feat(desktop): channel hover state and right-click mark-unread context menu (#578)

No conflicts.

* origin/main:
  fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
  fix(desktop): refine header scaling and shadow (#573)
  fix(desktop): keep day dividers below header (#574)
  Move agent activity below composer (#579)
  docs(nips): NIP-AE — Agent Engrams (#575)
  refactor: extract shared @mention resolver into sprout-sdk (#580)
  fix: add default-run to sprout-relay so `cargo run -p sprout-relay` works (#577)
  feat(desktop): channel hover state and right-click mark-unread context menu (#578)
tlongwell-block added a commit that referenced this pull request May 15, 2026
* origin/main: (33 commits)
  dev-mcp: add view_image tool (#602)
  fix(relay,desktop): only advertise NIP-43 when enforced; probe pairing by supported_nips (#601)
  fix(desktop): derive unread state from NIP-RS + relay catch-up only (#599)
  docs(testing): rewrite TESTING.md for current API and CLI-first workflow (#597)
  fix(agent): fix OpenAI-compat request body serialization and max_tokens (#595)
  feat(desktop): per-persona and per-agent env var overrides (#594)
  fix(desktop): stop pinning agents to deprecated SPROUT_ACP_TURN_TIMEOUT (#592)
  fix(desktop): populate member_count in get_channels so channel browser shows real counts (#548)
  fix(desktop): autofocus message composer on channel/thread open (#572)
  refactor(cli): restructure flat commands into 12 subcommand groups (#585)
  feat(sdk): add builder functions for workflows, DMs, and presence (#589)
  feat(desktop): add message more-actions dropdown menu (#590)
  fix(mobile): preserve channel list across background/resume reconnection (#588)
  Redesign Home as an inbox (#582)
  fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
  fix(desktop): refine header scaling and shadow (#573)
  fix(desktop): keep day dividers below header (#574)
  Move agent activity below composer (#579)
  docs(nips): NIP-AE — Agent Engrams (#575)
  refactor: extract shared @mention resolver into sprout-sdk (#580)
  ...

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant