npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

openclaw-nostr

v2026.9.45

Published

Nostr channel plugin for OpenClaw

Downloads

2,363

Readme

openclaw-nostr

Nostr channel plugin for OpenClaw — encrypted DMs, public notes, identity resolution, relay discovery, public channels, and 20+ NIP implementations.

Overview

This extension adds Nostr as a full-featured messaging and social channel to OpenClaw. It enables your agent to:

  • Receive and send encrypted DMs via NIP-04 and NIP-17 (gift-wrapped)
  • Publish and react to public notes (kind:1)
  • Resolve NIP-05 identities and discover relay capabilities
  • Create and moderate public channels (NIP-28)
  • Repost content, publish file metadata, and generate zap requests
  • Encrypt messages with NIP-44 versioned encryption
  • Authenticate to relays (NIP-42) and HTTP services (NIP-98)
  • Delegate signing to a remote NIP-46 bunker (no local private key needed)
  • Access 20+ additional NIP implementations from nostr-tools

Requirements

  • OpenClaw ≥ 2026.7.1 is required for the full NIP-29 workroom loop control. The pair-scoped bot-loop guard (core bot-loop-protection) and the SenderIsBot fact only exist on ≥ 2026.7.1. On older hosts the plugin still loads and degrades gracefully — the in-plugin allowBots gate and the opt-in room-wide backstop still run — but the pair-scoped guard is unavailable.
  • Fleet operators: upgrade every agent to ≥ 2026.7.1. In a shared NIP-29 room, one un-upgraded agent still can't loop you (the gate is per-agent), but it won't get the pair-scoped protection itself. Bump the whole fleet so the loop control is uniform. See docs/UPGRADE-WORKFLOW.md and docs/COMPATIBILITY.md.

NIP-29 workroom loop control

Agents sharing a room used to echo each other and loop indefinitely. Two layers now prevent that, mirroring how Slack/Discord/Feishu handle bot-to-bot traffic:

  1. allowBots gate (default "mentions") — a known peer agent (a member whose NIP-24 kind:0 sets bot: true) only earns a turn when it explicitly @mentions this agent. "off" ignores all bot messages; "all" answers freely. Humans and unknown senders are never affected. Set per room under channels.nostr.nostr_channels.<room>.config.allowBots.
  2. Core bot-loop pair guard (≥ 2026.7.1) — for an admitted bot turn, the core guard suppresses only the specific looping bot pair (sliding-window budget + cooldown), so unrelated room work continues. Tunable per room via config.botLoopProtection (maxEventsPerWindow / windowSeconds / cooldownSeconds).
  3. ACK reactions (default on) — in NIP-29 rooms, a pure acknowledgement such as “got it”, “on it”, “thanks”, or 👍 becomes a NIP-25 ✅ reaction when the outbound reply target is known. No chat event is posted, so the ACK does not trigger more agent turns. Set per-room config.ackAsReaction: false to keep ACKs as chat messages. DMs, untargeted sends, substantive messages, and Communikeys rooms are unchanged.

Publish profile.bot: true on every fleet agent so peers can identify each other.

Alongside the enforcement layers, the plugin ships a nostr-workroom skill (skills/nostr-workroom/SKILL.md) that teaches agents the positive protocol the gates can't encode: reply discipline, the @mention handoff contract, commitment→tracked-task rules, and where coordination belongs. Keep per-room config.systemPrompt to room-specific facts — the doctrine is versioned here.

Installation

Normal install

openclaw plugins install npm:openclaw-nostr

openclaw-nostr is a runtime-only plugin artifact. It contains no bootstrap scripts or process-spawning code, so OpenClaw's install-time security scanner can scan and install it normally. The OpenClaw plugin id remains nostr.

Optional legacy migration

If you previously installed the broken @openclaw/nostr, the old combined nostr-claw-bootstrap package, or a direct-copy override, run:

npx openclaw-nostr-bootstrap

The migration CLI:

  1. detects the host OpenClaw version
  2. removes recognized stale direct-copy/activation installs and broken managed npm entries left by prior packages
  3. runs openclaw plugins install npm:openclaw-nostr --force
  4. enables the plugin in openclaw.json
  5. refreshes the persisted plugin registry (via the install)
  6. runs a best-effort plugin-graph smoke test

For an offline migration, install from a local plugin checkout with --plugin-source:

npx openclaw-nostr-bootstrap --plugin-source /path/to/openclaw-nostr

With an explicit OpenClaw checkout / CLI path:

npx openclaw-nostr-bootstrap --openclaw /path/to/openclaw.mjs

For machine-readable output:

npx openclaw-nostr-bootstrap --json

From this repo (Cascadia fork)

git clone https://git.sharegap.net/cascadia/openclaw-nostr.git

See Docker Deployment for containerized setups.

Cascadia Fleet Role

This repo is the durable maintenance layer for Cascadia's Nostr fixes when upstream OpenClaw upgrades clobber local agent installs.

The repository now publishes two deliberately separate packages:

  • openclaw-nostr: the runtime-only OpenClaw plugin and normal install artifact
  • openclaw-nostr-bootstrap: an optional migration CLI for legacy installs

Upstream OpenClaw remains the base runtime, while this repo is the source of truth for Nostr-specific durability patches and extended features.

See:

  • docs/UPGRADE-WORKFLOW.md
  • docs/COMPATIBILITY.md
  • scripts/apply-to-agent.sh
  • scripts/check-agent.sh

Publishing (maintainers)

Two independent npm packages are published from this repo. They share a version but are released separately.

1. The plugin — openclaw-nostr (runtime-only artifact users install):

# from the repo root
npm login                       # once, if not already authenticated
npm publish --dry-run           # inspect the tarball: no scripts/, cascadia-ts bundled, 0 child_process
npm publish --access public     # publish openclaw-nostr@<version>

The published tarball is runtime-only (files[] excludes scripts/) and bundles cascadia-ts via bundledDependencies, so it installs and passes OpenClaw's install-time security scanner cleanly. Verify before publishing:

npm run check:hermetic-pack  # pack, inspect, install, and load every exported entry
npm run test:scanner         # asserts 0 critical scanner findings

check:hermetic-pack uses a temporary directory outside the repository and installs the published OpenClaw version range from devDependencies, so it requires network access. It verifies the runtime import closure, bundled cascadia-ts, absence of tests/docs/beads files, copied (not symlinked) installation, and host-loader imports.

npm run check:host-members also requires network access. It installs the published OpenClaw release channels in temporary directories and typechecks against their host types, failing only when a runtime property or named export used here has been removed or renamed.

2. The migration CLI — openclaw-nostr-bootstrap (optional, npx-run):

cd packages/openclaw-nostr-bootstrap
npm publish --dry-run
npm publish --access public     # publish openclaw-nostr-bootstrap@<version>

This package intentionally ships scripts/ (which spawn child_process). That is fine — it is an npx CLI, never installed as an OpenClaw plugin, so the plugin scanner never runs against it.

Publishing to a private/self-hosted registry instead of public npm: add --registry https://<registry> to each npm publish, or set publishConfig.registry in the respective package.json.

Bump the version in both package.json files before a coordinated release (the plugin at the repo root and packages/openclaw-nostr-bootstrap/package.json).

Quick Setup

  1. Generate a Nostr keypair (if you don't have one):

    # Using nak CLI
    nak key generate
    
    # Or use any Nostr key generator
  2. Add to your config (~/.openclaw/openclaw.json):

    {
      "channels": {
        "nostr": {
          "privateKey": "${NOSTR_PRIVATE_KEY}",
          "relays": ["wss://relay.sharegap.net", "wss://nos.lol"]
        }
      }
    }
  3. Set the environment variable:

    export NOSTR_PRIVATE_KEY="nsec1..."  # or 64-char hex format
  4. Restart the gateway

Configuration

| Key | Type | Default | Description | | ------------ | -------- | ------------------------------------------- | ---------------------------------------------------------- | | privateKey | string | required* | Bot's private key (nsec or hex). *Not required when using NIP-46. | | relays | string[] | ["wss://relay.sharegap.net", "wss://nos.lol"] | WebSocket relay URLs | | dmPolicy | string | "pairing" | Access control: pairing, allowlist, open, disabled | | allowFrom | string[] | [] | Allowed sender pubkeys (npub or hex) | | enabled | boolean | true | Enable/disable the channel | | name | string | - | Display name for the account | | progressLanes | object | { "reasoning": false, "commentary": false } | Default reasoning/commentary payload delivery for all agents | | agents.<agentId>.progressLanes | object | inherits progressLanes | Per-agent reasoning/commentary overrides |

Reasoning and commentary progress lanes

Reasoning (🧠) and commentary (💬) payloads are not sent over Nostr by default. Enable either lane globally, then override individual booleans per routed agent as needed:

{
  "channels": {
    "nostr": {
      "progressLanes": { "reasoning": false, "commentary": false },
      "agents": {
        "ops": {
          "progressLanes": { "commentary": true }
        }
      }
    }
  }
}

An agent override wins for each configured lane; omitted override values inherit the global setting. Unknown agent IDs use the global setting. Enabled payloads follow the existing reply shape: normal DMs stream each payload immediately, while group messages, membership events, group reactions, and DM reactions buffer payloads into their existing combined reply.

Watchdog heartbeats are independent of these display settings: host agent events for the routed session keep an active turn alive even when reasoning/commentary lanes are disabled and no progress message is sent over Nostr.

NIP-46 Remote Signing

When NIP-46 is enabled, event signing and encryption/decryption are delegated to a remote signer (bunker). No private key is stored locally — only a client secret used for the encrypted communication channel.

| Key | Type | Default | Description | | ------------------------ | -------- | --------- | ------------------------------------------------------------------ | | nip46 | boolean | false | Enable NIP-46 remote signing | | nip46BunkerUrl | string | - | bunker:// URL or user@domain NIP-05 identifier | | nip46SignerRelays | string[] | - | Relay URLs for signer communication (defaults to bunker URL relays) | | nip46Secret | string | - | Client secret key (hex) — use env var, not raw config | | nip46ConnectionTimeoutMs | number | 60000 | Connection timeout for the NIP-46 session |

NIP-46 Setup

  1. Set up a bunker signer (e.g. nsecBunker, Amber, or any NIP-46 compatible signer)

  2. Generate a client secret (a random 32-byte hex key for the encrypted channel):

    # Generate a random client secret
    openssl rand -hex 32
  3. Store the client secret securely as an environment variable:

    export NOSTR_NIP46_SECRET="your-64-char-hex-client-secret"
  4. Configure in openclaw.json:

    {
      "channels": {
        "nostr": {
          "nip46": true,
          "nip46BunkerUrl": "bunker://abcdef...?relay=wss://relay.nsecbunker.com",
          "relays": ["wss://relay.sharegap.net", "wss://nos.lol"]
        }
      }
    }

    Or with a NIP-05 bunker identifier:

    {
      "channels": {
        "nostr": {
          "nip46": true,
          "nip46BunkerUrl": "[email protected]"
        }
      }
    }
  5. Alternatively, use a SecretRef to point to the env var explicitly:

    {
      "channels": {
        "nostr": {
          "nip46": true,
          "nip46BunkerUrl": "bunker://abcdef...?relay=wss://relay.nsecbunker.com",
          "nip46Secret": { "source": "env", "provider": "default", "id": "NOSTR_NIP46_SECRET" }
        }
      }
    }

Secret Sources

The nip46Secret field supports three SecretRef source types for production deployments:

| Source | Example | Description | | ------ | ------- | ----------- | | env | { "source": "env", "name": "NOSTR_NIP46_SECRET" } | Read from environment variable (id or name field) | | file | { "source": "file", "path": "/run/secrets/nip46" } | Read from a file (Docker secrets, tmpfs, etc.) | | exec | { "source": "exec", "command": "vault kv get -field=secret nostr/nip46" } | Run a command, use stdout (10s timeout) |

All sources fall back to the NOSTR_NIP46_SECRET environment variable if the primary source fails.

Enrollment Flow

The plugin provides a programmatic enrollment flow (enrollNip46Signer()) that automates the full NIP-46 setup ceremony:

  1. Generates a client secret (or reuses an existing one)
  2. Validates and parses the bunker URL
  3. Connects to the remote signer
  4. Verifies the remote pubkey matches expectations
  5. Runs a self-test on all delegated crypto operations
  6. Produces a config patch and env var instructions

Cutover Migration

To migrate from local key to NIP-46 remote signing, use performCutover() which executes a transactional migration:

  1. Validates a local private key exists
  2. Snapshots the current config as backup
  3. Runs the enrollment flow (connect, verify, self-test)
  4. Applies the new config (enables NIP-46, removes privateKey)
  5. Runs post-cutover verification
  6. Automatically rolls back to the original config if any step fails

Preflight Doctor

The runNip46Doctor() function performs 7 diagnostic checks:

| Check | What it verifies | | ----- | ---------------- | | config_valid | Bunker URL and client secret are provided | | bunker_url_parsed | URL yields a pubkey (or is valid NIP-05) | | client_secret_decodable | Hex secret decodes to 32 bytes | | signer_connect | BunkerSigner connects within timeout | | pubkey_match | get_public_key matches expected identity | | crypto_self_test | sign, nip44, nip04 all work end-to-end | | secret_recoverable | Client secret re-decodes consistently (restart safety) |

What Gets Delegated

When NIP-46 is active, all identity-level cryptographic operations go through the remote signer:

  • Event signing (DMs, notes, reactions, deletions, reposts, channel messages, group messages)
  • NIP-04 encryption and decryption
  • NIP-44 encryption and decryption (used in NIP-17 gift wraps)
  • NIP-42 relay authentication
  • NIP-98 HTTP authentication

The client secret (nip46Secret) is only used to establish the NIP-44 encrypted channel with the bunker — it is not the identity private key.

Startup Self-Test & Health Logging

On startup with NIP-46 enabled, the plugin:

  1. Logs a signer health block showing signing mode, identity pubkey, bunker URL, relay list, and session status
  2. Runs an automatic self-test exercising all delegated crypto operations:
    • get_public_key — returns valid 64-char hex pubkey matching expected identity
    • sign_event — signs a test kind:1 event
    • nip44_encrypt_decrypt — round-trips NIP-44 encryption
    • nip04_encrypt_decrypt — round-trips NIP-04 encryption
  3. Tracks runtime health (success/failure counts, last sign/decrypt/encrypt timestamps)

Failures are logged as warnings — the bus still starts, but you'll know which operations aren't working.

Custody Status

The resolveCustodyStatus() function reports the current signing posture:

  • Signing mode: local-key, nip46-remote, or unconfigured
  • Key residency: whether a local private key still exists in config
  • NIP-46 readiness: bunker URL + secret configured
  • Runtime health: signer success/failure counts and last operation timestamps
  • Restart safety: whether the secret source will survive a restart
  • Warnings: actionable alerts (e.g. "local key still present after NIP-46 migration")

Rate-Limit Protection

NIP-46 operations go through a request queue with:

  • Concurrency limit (default: 2 concurrent requests)
  • Stagger interval (default: 150ms between dispatches)
  • Exponential backoff retry on rate-limit errors (1s → 2s → 4s, up to 3 retries)

This prevents bursts of inbound messages from overwhelming the bunker relay with rapid-fire publishes.

Outbound DM Safety Assertions

The NIP-17 outbound path includes runtime assertions that prevent routing bugs:

  • Recipient pubkey must not equal own pubkey (no self-DM)
  • Recipient pubkey must be valid 64-char hex
  • Post-wrap p tags must match the intended recipient for each wrap
  • Self-echo wraps are logged and ignored on the inbound path

Security Considerations

  • Never commit the client secret to config files or version control
  • The NOSTR_NIP46_SECRET env var is the recommended storage method
  • The SecretRef mechanism supports env, file, and exec backends for production deployments
  • The bunker must be online for the agent to sign events — plan for connectivity

Room Channel Kinds

channels.nostr.nostr_channels.<room>.kind selects the community plane:

| Kind | Plane | Addressed by | Use when | | ---- | ----- | ------------ | -------- | | nip29 | Relay-enforced group | group_address | The relay operator is trusted to enforce membership | | communikey | Public community (NIP-CAS-0007 / Communikeys V2) | address — a kind-32222 community-definition naddr or 32222:<owner-pubkey>:<community-id> (a bare pubkey does not identify a branch) | Default. Coordination that can be public | | concord | Encrypted backup (NIP-CAS-0008) | community_id | The coordination itself must be private |

Concord — encrypted backup rooms (NIP-CAS-0008)

Concord is an optional end-to-end encrypted community plane adopted from the Concord protocol (CORD-01/02/03/05). Relays see only NIP-59-shaped kind-1059 wraps: membership, authority, channel structure, and message content are all sealed. Communikeys remains the primary plane — reach for Concord when the membership set or the discussion itself is sensitive (incident response, pre-disclosure security work), not by default.

{
  "channels": {
    "nostr": {
      "nostr_channels": {
        "incident": {
          "kind": "concord",
          // CORD-02 §1 owner commitment, 64-char lowercase hex. Not secret
          // (it travels in invites) but never appears on the wire.
          "community_id": "<64-hex>",
          // CORD-05 join material, via the standard SecretRef mechanism.
          // Raw key material is NEVER accepted inline in config.
          "keys": { "source": "env", "name": "CONCORD_INCIDENT_KEYS" },
          "channel": "general",
          "relays": ["wss://relay.example"]
        }
      }
    }
  }
}

The keys SecretRef resolves to the CORD-05 bundle JSON (community_id, owner, owner_salt, community_root, root_epoch, control_pk, channels[]). It is optional: omit it and the agent will onboard its keys from a CORD-05 Direct Invite (kind 3313) gift-wrapped to its own npub, then publish a Guestbook Join.

Currently supported: CORD-01 stream wraps, CORD-02 Control-Plane editions folded into an owner-rooted CORD-04 roster, the Guestbook (3306/3309 and 3312 snapshots), CORD-03 public and private channels with strict channel/epoch replay binding, the full CORD-03 §3 Chat plane (kind-9 messages, kind-1111 threaded replies, kind-7 reactions, kind-5 deletes, kind-3302 edits), CORD-02 §9 dissolution, CORD-05 §6 Direct Invites, and CORD-06 rekeys and refoundings.

Concord separates the two ways one message cites another, and so does this plugin: an inline quote stays a kind 9 with a q tag and surfaces as meta.quote, while a threaded reply is a kind 1111 whose lowercase e/k/p pin the immediate parent and uppercase E/K/P the immutable root. Replying to a message the agent has seen mints a real 1111 that inherits its parent's root verbatim, so threads stay stable at any depth; replying to something it never saw degrades to an inline quote rather than fabricating a root. Deletes and edits apply to their target by rumor id and are honored only from the target's own author (or, for a delete, a MANAGE_MESSAGES holder) — including when they arrive before the message they modify. Concord rooms therefore also support the ack-as-reaction and status-reaction seams.

The agent holds every epoch it has ever been given, so a membership removal or a base Refounding no longer strands it: it watches the precomputed next-epoch rekey addresses, folds kind-3303 blobs after checking the rotator's authority and the A.5 epoch-key commitment, and keeps the prior epochs subscribed so history spanning a rotation stays readable. A rotation whose chunk set completes without a blob for this account is a removal, and the channel then refuses to send rather than publishing into a dead epoch.

A Refounding starts the new epoch's Guestbook empty, so the refounder seeds it with a chunked kind-3312 snapshot of the survivors. That snapshot is honored only from the npub whose Refounding minted the epoch, and it is secondhand — it merely seeds a member's state, and any newer (or same-instant) self-signed Join/Leave or authorized Kick supersedes it. Absence from a snapshot is never a negative state, so a partial one seeds whoever it carries and the rest heal by observation.

NIP-46 remote signers are supported: outbound seals already route through the signer, and both giftwrap layers of a Direct Invite are decrypted with the bunker's nip44_decrypt, so a bunker-backed agent onboards its keys normally. The one exception is CORD-06 rekey blobs — their plaintext is raw binary and NIP-46 returns decryption results as strings, so the bytes cannot survive the round trip. A bunker-backed agent logs that at channel start, reports any rotation it had to skip, and needs fresh keys material after a Refounding.

CORD-08 disappearing messages are supported. The timer is Community state, not a per-message choice: message_expiration (seconds) folds from the MANAGE_METADATA-gated Community metadata, and absent, 0, or malformed all mean off — never a guessed default. While it is set, outbound Chat rumors carry a NIP-40 expiration computed from their own created_at, and the outer wrap repeats it so honoring relays delete the ciphertext itself. Deletes (kind 5) are exempt, since an expiring tombstone would let the message it erased come back. Inbound rumors past their signed expiry are refused at ingest. A timer change is never retroactive: the tag rides inside the signed rumor, so a message keeps the expiry it was sent under. Kind-1740 timer notices fold as informational only, and only from a MANAGE_METADATA holder — the metadata fold stays the authority.

CORD-04 authority folds as a real delegation chain rather than owner-only: Roles and Grants are honored from any actor holding MANAGE_ROLES who strictly outranks both the Roles involved and the member acted on, traced back to the owner (position 0, proven by the community_id itself). Equal never acts on equal, and no edition may claim a position at or above its own signer. Editions chain by CORD-04 §1 edition hash, so a successor citing a prev this client does not hold is a gap that suspends the entity rather than a head to seat — while a fresh joiner still accepts a compaction's dangling head as its baseline. The vac citation is enforced as a sync floor: an action (including a Kick) cites the exact Grant it acts under, pinned by coordinate, version and content hash, and parks until that Grant is synced — then resolves against the current roster, so a just-demoted actor is never grandfathered and a forged citation simply never resolves. Anything parked is re-judged whenever the roster moves, so a delegated chain converges however the relay ordered it.

The CORD-04 §4 Banlist is retroactive, not merely a refusal going forward. Every event from a banned npub is dropped — chat, Guestbook entries, CORD-06 rotations, and Control editions, which is the half that takes real machinery: an edition the banned actor already seated is not authority either, so the fold reverts to the newest edition from a non-banned actor (§7), unpinning innocent authors' messages as collateral where the entity is a Pin List, and unseating the entity entirely where no such edition remains. That takes a bounded per-entity backlog of superseded editions rather than a filter. A ban therefore severs a delegation chain at whatever link the banned actor signed — the Role they minted, or the Grant that handed it out — so their delegates lose the authority that traced to the owner only through them. Survivors are republished by any authorized holder, the §4 re-heal; the fold never re-derives a list nobody signed.

Which Banlist entries a client honors is derived, never stored. An entry is an action on the npub it names, so §5's one hard rule reaches it: the signer must hold BAN and strictly outrank the target — "an admin cannot ban a peer admin" — judged against the current roster and re-judged every time that roster moves. The owner's entry is never honored at all: they are supreme and unremovable (§2), and honoring it would let a single BAN holder silence the root of all authority. An entry whose target the signer does not outrank is inert, not refused — the edition still folds and chains, because refusing a whole replace-entire list over one entry would fork the version chain between implementations — and the entry lies dormant in the head on the wire, gating nothing and reverting nothing, until the target's rank drops below its author's. That is the normal case rather than the exception: §6 composes a Ban as the Banlist edition first, the Grant strip alongside, and once the strip lands the entry arms itself for every client, so a §4 re-heal by a lower-ranked admin carries a higher-ranked admin's entry through instead of silently stripping it. Because both sides are read live, the reverse holds too — a demoted admin's list goes dormant with them (§5: a just-demoted member's stale action is dropped), and the cure is the same re-heal: any current staffer republishes the list under their own signature.

CORD-04 §7 Pins lift a message onto the Control Plane, where compaction re-wraps it across every rotation — so a pin reaches members who never held the Chat plane's keys. A pin therefore does not quote a message, it proves one: the entry carries the author's own kind-20013 seal plus that single message's 76-byte NIP-44 key expansion, which is one-way, so disclosing it exposes exactly that message and nothing else about the conversation. Every entry is verified end to end — seal signature, MAC under the disclosed hmac_key, decrypt/unpad, rumor.pubkey == seal.pubkey, kind 9/1111, and a strict channel binding whose absence fails (without it a private Channel's keyholder could pin its messages into a public list, disclosing them Community-wide with proof) — and the entry's identity is the rumor id recomputed from the decrypted bytes. An invalid entry is dropped while its edition folds normally.

Both content forms are supported and self-describing: plaintext for a public Channel, sealed under the Channel key at a named epoch for a private one, with a writer always using the form the Channel's folded type demands. The two caps — 25 entries, 32,768 bytes of content — are content-level: a violating edition still folds, chains and compacts, and every reader treats it as an empty list, so no two implementations fork the version chain. A writer enforces both before publishing, because publishing one would cost it the whole list. A correction rides the entry as its own proof bundle (kind 3302, same author, same Channel, naming the original), never as the curator's words; a client holding a newer Edit marks the pin edited rather than rendering superseded words as current. Replace-entire cuts both ways, so a client that cannot read the current list — sealed under an epoch it never held, or Control not yet synced — withholds the write, rendering the pins as unavailable instead. Self-erasure outranks curation: an author's own kind-5 hides the entry immediately by recomputed id (a moderator's MANAGE_MESSAGES delete does not), and a PIN_MESSAGES holder publishes the omitting edition — at once if it pinned it, after a short random re-check otherwise, so simultaneous curators collapse to one publisher.

CORD-05 §5 invite-link Registry folds as the member-facing shadow of a creator's private Invite List. Each creator publishes a vsk 8 entity at a coordinate bound to (community_id, creator), so a CREATE_INVITE holder can write only their own list and nobody can forge links into anyone else's. Its content is the live links' coordinates only — the kind-33301 link-signer pubkeys, never tokens, URLs, or signing secrets — so members can see that links exist without being able to use one. Every creator's list folds into one aggregate active set, honored only while its author still holds CREATE_INVITE (a banned creator needs no check here — the fold unseats their Registry outright, §4 above), and that set is the Public/Private source of truth: non-empty means a live link exists and the Community is Public, empty means Private. A retired link simply stops being listed by the next edition, and emptying the last one is what flips the Community back to Private.

CORD-05 §1–§3 link invites can be followed as well as Direct Invites. A shareable link is a public locator plus a secret: a bare naddr naming (kind 33301, link_signer, d="") in the path, and a #fragment — never sent to any server — carrying the 16-byte unlock token and up to three bootstrap relays in the §3 dictionary encoding, so the common invite spends zero bytes on relays and an unknown dictionary generation is refused rather than decoded against the wrong table. manager.acceptInviteLink(channel, url) fetches the bundle at that exact coordinate, opens it with hkdf(token, "concord/invite-key"), and adopts it. The whole coordinate is checked, which makes the guard airtight in both directions: a squatter's event at a different author is a different coordinate, and a link-holder cannot replace or tombstone the bundle either. A retired link's vsk 9 tombstone is read as a refusal, and is exactly as durable as the bundle it replaced; a bundle that fails to reproduce its community_id, names another Community, or has passed expires_at refuses the join. Nothing subscribes or announces presence until the link is followed explicitly — a bundle is passive.

CORD-05 §2/§4 minting is the creator's side of the same machinery, and it moves three documents in one deliberate order: the Invite List (kind 13303, NIP-44 to self) first, because it is the only place a link signer's secret exists and a bundle whose secret was never recorded is a live link nobody can ever refresh or retire; the bundle second; the Registry edit last, because that is what tells the Community a live link exists. An entry whose bundle failed to post is repaired by minting again — one of those failures is recoverable and the other is not. manager.mintInviteLink(channel, opts) mints a signer used for nothing else and a fresh 16-byte token, and returns the URL; refreshInviteLink re-posts the bundle at the same coordinate with the keys held now, so a link shared once survives every rotation, reading what the link grants back off the live bundle rather than guessing; retireInviteLink posts the vsk 9 tombstone, buries the token in the Invite List, and republishes the Registry without it. A link grants no private Channel key unless one is named by id (opts.channelIds): a link is a public artifact reaching whoever it reaches, so the default hands over the base access key alone — every public Channel and nothing more. CREATE_INVITE is checked before anything is published, since without the Registry edit a link is invisible to the Community. The Invite List merges across a creator's devices by token, tombstones union, and a tombstone beats an entry terminally — no timestamp tiebreak, unlike a re-joinable membership — so a stale device can never resurrect a link that is already a grave on the wire. syncInviteList merges every copy the relays hold rather than taking the newest, because two devices publishing concurrently leave two different newest copies and either one alone drops the other's signer secrets. Retiring the last live link empties the aggregate Registry and reports nowPrivate; the severance itself is a Refounding (CORD-06) and a separate step, since a retired link stops being usable and the keys it handed out do not.

CORD-02 §8 Community List (manager.syncCommunityList()) merges this bus's memberships into the kind-13302 replaceable, NIP-44 encrypted to self. It merges rather than overwrites, because two clients can serve one npub and a device that knows about fewer Communities must never erase the others': seed keeps the lower epoch (the full-history backfill anchor, which only moves backward), current the higher, an epoch tie breaks on the lexicographically lowest canonical bytes so two devices never flap, tombstones union permanently and a tombstoned entry stays in the document, and the newest of added_at and removed_at decides whether a membership is live. Snapshots carry the membership subset plus the control_root a staffer holds — never the icon, never the link's expiry or attribution — and unknown fields survive the round trip. Both the 50-membership ceiling and the NIP-44 plaintext cap are checked before publishing. Under a NIP-46 remote signer this refuses loudly: the List is encrypted to self and there is no remote nip44_encrypt path, so encrypting to the throwaway local key would publish a List the account could never read back.

Deferred: A/V (CORD-07) and the 3310 WebXDC peer signal.

Key custody: a Concord community_root is a membership credential. Store it with the same care as a signing key, and found fleet communities from a key the fleet can afford to lose — a Concord owner key has no succession path.

Access Control

DM Policies

  • pairing (default): Unknown senders receive a pairing code to request access
  • allowlist: Only pubkeys in allowFrom can message the bot
  • open: Anyone can message the bot (use with caution)
  • disabled: DMs are disabled

Example: Allowlist Mode

{
  "channels": {
    "nostr": {
      "privateKey": "${NOSTR_PRIVATE_KEY}",
      "dmPolicy": "allowlist",
      "allowFrom": ["npub1abc...", "0123456789abcdef..."]
    }
  }
}

Protocol Support

Core Messaging (Tier 0)

| NIP | Kind(s) | Status | Description | | ------ | ------------ | ----------- | ------------------------------------- | | NIP-01 | 1 | ✅ Full | Basic event structure & public notes | | NIP-04 | 4 | ✅ Full | Encrypted DMs (legacy) | | NIP-09 | 5 | ✅ Full | Event deletion | | NIP-10 | — | ✅ Full | Thread references (root/reply/mention)| | NIP-17 | 1059 | ✅ Full | Gift-wrapped DMs (modern) | | NIP-25 | 7 | ✅ Full | Reactions | | NIP-40 | — | ✅ Full | Event expiration | | NIP-65 | 10002 | ✅ Full | Relay list metadata |

Tier 1 — Agent-Essential Features

| NIP | Kind(s) | Status | Description | | ------ | ------------ | ----------- | ------------------------------------- | | NIP-05 | — | ✅ Full | Identity resolution + domain search | | NIP-11 | — | ✅ Full | Relay information + capability checks | | NIP-42 | 22242 | ✅ Full | Relay authentication | | NIP-46 | 24133 | ✅ Full | Remote signing (Nostr Connect/Bunker) | | NIP-57 | 9734, 9735 | ✅ Re-export| Zaps (Lightning payments) | | NIP-94 | 1063 | ✅ Full | File metadata | | NIP-98 | 27235 | ✅ Full | HTTP authentication | | NIP-B7 | — | ✅ Re-export| Blossom media server |

Tier 2 — Social & Channel Features

| NIP | Kind(s) | Status | Description | | ------ | ------------------ | ----------- | ------------------------------- | | NIP-13 | — | ✅ Re-export| Proof of Work | | NIP-18 | 6, 16 | ✅ Full | Reposts (short text + generic) | | NIP-27 | — | ✅ Full | Content parsing (text/URLs/refs)| | NIP-28 | 40, 42, 43, 44 | ✅ Full | Public channels (CRUD + mod) | | NIP-44 | — | ✅ Full | Versioned encryption |

Tier 3 — Niche / Advanced (Namespace Re-exports)

| NIP | Module | Description | | ------ | ------------------ | ------------------------------------- | | NIP-29 | nostr-extras | Relay-based groups | | NIP-30 | nostr-extras | Custom emoji | | NIP-39 | nostr-extras | External identity verification | | NIP-47 | nostr-extras | Nostr Wallet Connect (NWC) | | NIP-49 | nostr-extras | Private key encryption (ncryptsec) | | NIP-58 | nostr-extras | Badges | | NIP-75 | nostr-extras | Zap goals (fundraising) | | NIP-77 | nostr-extras | Negentropy sync |

Architecture

The plugin follows a three-layer architecture:

nostr-capabilities.ts   ← Event builders (pure functions, no I/O)
nostr-discovery.ts      ← NIP-05/NIP-11/NIP-27 (network I/O with caching)
nostr-extras.ts         ← Tier 3 namespace re-exports
nip46-signer.ts         ← NIP-46 signer abstraction (NostrSigner interface)
nip46-doctor.ts         ← NIP-46 preflight diagnostics (7-check health report)
nip46-enroll.ts         ← NIP-46 enrollment flow (generate secret, connect, verify)
nip46-cutover.ts        ← NIP-46 migration (local key → remote signer, auto-rollback)
nip46-status.ts         ← Custody status reporting (signing mode, health, warnings)
        │
nostr-bus.ts            ← Runtime wiring (signing, publishing, subscriptions)
        │
channel.ts              ← Public API (OpenClaw plugin interface)
        │
nostr-profile-http.ts   ← HTTP endpoints (/api/channels/nostr/...)

Key Design Decisions

  • Unsigned templates: All event builders return EventTemplate objects. The bus layer handles signing via finalizeEvent (local) or NostrSigner.signEvent (NIP-46) and publishing via the pool. This keeps builders pure and testable.
  • NIP-46 signer abstraction: The NostrSigner interface (nip46-signer.ts) provides a unified API for signing, encryption, and decryption. When NIP-46 is enabled, all bus operations delegate to the remote BunkerSigner through a request queue (rate-limit protection) with retry logic; otherwise they use the local secret key. The abstraction is transparent to upstream consumers.
  • NIP-46 lifecycle modules: Enrollment (nip46-enroll.ts), cutover migration (nip46-cutover.ts), preflight diagnostics (nip46-doctor.ts), and custody status (nip46-status.ts) are standalone modules that can be called programmatically or wired into CLI commands.
  • NIP-28 custom builders: The upstream nostr-tools/nip28 functions call finalizeEvent internally. We provide our own builders that return unsigned templates to fit the fork's signAndPublish pattern.
  • Caching: NIP-05 uses a 5-minute TTL with 500-entry LRU. NIP-11 uses a 10-minute TTL with 100-entry LRU. Network errors are not cached (retry on next call).
  • Per-sender serialization: Inbound messages from the same pubkey are processed serially to prevent race conditions during relay EOSE bursts (see PATCHES.md).
  • Bounded startup catch-up: DM subscriptions first query a capped historical window through startup time, then promote to live subscriptions after EOSE/timeout so relay backlog and live traffic have distinct lifecycles.
  • Real outbound IDs: Outbound channel messageId values are real Nostr identifiers. NIP-04 returns the signed kind:4 event ID; NIP-17 returns the recipient rumor ID after at least one recipient wrap is accepted while the bus also tracks accepted gift-wrap IDs.

HTTP API

All endpoints are under /api/channels/nostr/:accountId/. Authentication is handled by the OpenClaw gateway.

Agent profile tools

When a configured Nostr account is available to the invoking session, the plugin exposes:

  • nostr_profile — reads that account's configured kind-0 profile, identity pubkey, general relays, and last publish state.
  • nostr_profile_set — merges profile fields, supports explicit field removal through clear, publishes kind-0 metadata, and persists the configuration after at least one relay accepts it.

Profile mutation changes the agent's externally visible Nostr identity. Operators should use OpenClaw tool allow/deny policy to deny or narrowly allow nostr_profile_set; the read-only nostr_profile tool does not expose signing or NIP-46 secrets.

At every Nostr bus start or reload, profile synchronization compares kind-0 content on the configured general relays (never dmRelays). Configuration wins on semantic content divergence and is republished once; when configuration has no profile, an existing remote kind-0 is imported into config without publishing. Matching content is a no-op, and individual missing relay copies are not repaired. Relay failures are non-fatal. Set channels.nostr.profileSync: false to disable startup synchronization (default: true).

Profile Management

| Method | Endpoint | Description | | ------ | ------------------------------- | --------------------------- | | GET | /profile | Get current profile state | | PUT | /profile | Update and publish profile | | POST | /profile/import | Import profile from relays |

Identity & Discovery

| Method | Endpoint | Description | | ------ | ------------------------------- | -------------------------------------- | | GET | /identity/:nip05 | Resolve NIP-05 address to pubkey | | GET | /identity/search/:domain?q= | Search NIP-05 domain for users | | GET | /relay-info?url=wss://... | Get relay NIP-11 capability summary |

Events

| Method | Endpoint | Description | | ------ | ------------------------------- | --------------------------- | | POST | /note | Publish a public note | | POST | /reaction | React to an event | | DELETE | /events | Delete events |

Example: Resolve a NIP-05 Identity

curl http://localhost:18789/api/channels/nostr/default/identity/alice%40example.com
{
  "ok": true,
  "nip05": "[email protected]",
  "pubkey": "aabbccdd...",
  "relays": ["wss://relay1.example", "wss://relay2.example"]
}

Example: Check Relay Capabilities

curl "http://localhost:18789/api/channels/nostr/default/relay-info?url=wss://relay.sharegap.net"
{
  "ok": true,
  "url": "wss://relay.sharegap.net",
  "name": "Damus Relay",
  "supportedNips": [1, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40],
  "authRequired": false,
  "paymentRequired": false,
  "restrictedWrites": false
}

Docker Deployment

There are four ways to deploy this fork to an existing OpenClaw Docker setup, listed from simplest to most involved.

Option 1: Volume Mount (Recommended)

Mount the fork's source directory into the container and point OpenClaw's plugin loader at it. No image rebuild required.

1. Clone the fork on the Docker host:

git clone https://git.sharegap.net/cascadia/openclaw-nostr.git /opt/openclaw-nostr

2. Add to your docker-compose.yml:

services:
  openclaw-gateway:
    volumes:
      - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
      - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
      # Mount the fork's source
      - /opt/openclaw-nostr:/opt/openclaw-nostr:ro

3. Tell OpenClaw to load the plugin via openclaw.json:

{
  "plugins": {
    "load": {
      "paths": ["/opt/openclaw-nostr"]
    }
  },
  "channels": {
    "nostr": {
      "privateKey": "${NOSTR_PRIVATE_KEY}",
      "relays": ["wss://relay.sharegap.net", "wss://nos.lol"]
    }
  }
}

4. Restart:

docker compose restart openclaw-gateway

Option 2: Config Extensions Directory

Copy the fork into OpenClaw's user-level extensions directory, which is auto-scanned on startup.

# Copy into the config dir that's already mounted
cp -r /opt/openclaw-nostr "${OPENCLAW_CONFIG_DIR}/extensions/nostr"

# Restart
docker compose restart openclaw-gateway

OpenClaw discovers plugins from ~/.openclaw/extensions/ automatically — no plugins.load.paths config needed.

Option 3: Custom Dockerfile Layer

Build a derived image with the fork baked in. Best for CI/CD pipelines and reproducible deployments.

FROM openclaw:latest

# Copy in the fork
COPY openclaw-nostr /app/extensions/nostr

# The fork overrides the bundled nostr extension at the same path

Build and run:

docker build -t openclaw-nostr:custom .
OPENCLAW_IMAGE=openclaw-nostr:custom docker compose up -d

Option 4: Build-Arg with Full Source

If you're building OpenClaw from source, include the nostr extension via the OPENCLAW_EXTENSIONS build arg:

# From the openclaw source root
docker build \
  --build-arg OPENCLAW_EXTENSIONS="nostr" \
  -t openclaw:with-nostr .

This uses the extensions/nostr directory within the OpenClaw source tree. To use the fork instead, replace extensions/nostr with the fork's source before building.

Plugin Discovery Precedence

OpenClaw discovers plugins in this order (first match wins):

  1. plugins.load.paths — Explicit paths from config (Option 1)
  2. Workspace extensions<workspace>/.openclaw/extensions/
  3. User extensions~/.openclaw/extensions/ (Option 2)
  4. Bundled extensions/app/extensions/ inside the image (Options 3 & 4)

The fork at a higher-precedence path will shadow the bundled upstream version.

Docker + Durability Patches

If you also need the runtime durability patches (reconnect fix, subscription handling, etc.), apply them after image build or container start:

# For volume-mount setups, run against the container
docker exec -it openclaw-gateway bash -c '...'

# Or use the apply script against a Docker host
scripts/apply-to-agent.sh user@docker-host

See PATCHES.md for the full list of runtime patches.

Programmatic Usage

Channel-Level Functions

These are available from channel.ts and operate on named accounts:

import {
  // Messaging
  publishNostrNote,
  publishNostrReaction,
  deleteNostrEvents,
  // Identity
  resolveNostrIdentity,
  searchNostrDomain,
  validateNostrIdentity,
  // Discovery
  getNostrRelayInfo,
  getNostrRelayCapabilities,
  // Auth
  getNostrHttpAuthToken,
  getNostrBlossomAuthToken,
  // Media
  publishNostrFileMetadata,
  // Social
  repostNostrEvent,
  // Parsing
  parseNostrContent,
} from "./src/channel.js";

// Resolve a NIP-05 identity
const alice = await resolveNostrIdentity("[email protected]");
// { nip05: "[email protected]", pubkey: "aabb...", relays: ["wss://..."] }

// Check relay capabilities
const caps = await getNostrRelayCapabilities("wss://relay.sharegap.net");
// { supportedNips: [1, 4, ...], authRequired: false, ... }

// Parse content into structured blocks
const blocks = parseNostrContent("Hello https://example.com #nostr");
// [{ type: "text", ... }, { type: "url", ... }, { type: "hashtag", ... }]

Bus Handle (Direct Access)

For advanced use, get the bus handle from getActiveNostrBuses():

import { getActiveNostrBuses } from "./src/channel.js";

const bus = getActiveNostrBuses().get("default");

// Send a DM and keep the real Nostr ID for logging/threading
const sent = await bus.sendDm(recipientPubkey, "hello from OpenClaw");
// sent.eventId is the kind:4 ID for NIP-04, or the recipient rumor ID for NIP-17
// sent.publishedEventIds contains the signed event IDs accepted by relays

// NIP-44 encrypt a message
const key = bus.getNip44ConversationKey(recipientPubkey);
const encrypted = bus.nip44Encrypt("secret message", key);

// Create a public channel
const channelId = await bus.createChannel({
  name: "My Channel",
  about: "A public channel for discussion",
});

// Send a channel message
await bus.sendChannelMessage({
  channelId,
  content: "Hello channel!",
  relayUrl: "wss://relay.example",
});

// Generate BUD-11 auth token for a Blossom upload
const token = await bus.getBlossomAuthToken({
  verb: "upload",
  hashes: ["<sha256>"],
  server: "https://media.example.com",
});

Tier 3 Extras (Namespace Imports)

import { nip49, nip58, nip29, nip47, nip30, BlossomClient } from "./src/nostr-extras.js";

// NIP-49: Encrypt a private key for storage
const ncryptsec = nip49.encrypt(secretKey, "password");
const recovered = nip49.decrypt(ncryptsec, "password");

// NIP-47: Parse a Nostr Wallet Connect string
const connection = nip47.parseConnectionString("nostr+walletconnect://...");

// NIP-30: Find custom emoji in content
for (const match of nip30.matchAll(":custom_emoji: hello")) {
  console.log(match.shortcode, match.url);
}

Testing

Local Relay (Recommended)

# Using strfry
docker run -p 7777:7777 ghcr.io/hoytech/strfry

# Configure openclaw to use local relay
"relays": ["ws://localhost:7777"]

Running Tests

Tests run via vitest from the parent OpenClaw workspace:

# From the openclaw workspace root
pnpm vitest run --config vitest.extensions.config.ts extensions/nostr/

# Run a specific test file
pnpm vitest run --config vitest.extensions.config.ts extensions/nostr/src/nostr-discovery.test.ts

Test Coverage

| Test File | Covers | | -------------------------------------- | --------------------------------------------------------- | | nostr-bus.protocol.test.ts | NIP-04/NIP-17 DM pipeline, reply routing, serialization | | nostr-capabilities-extended.test.ts | NIP-18 reposts, NIP-28 channels, NIP-13/44/57/94/42/98 | | nostr-discovery.test.ts | NIP-05 identity, NIP-11 relay info, NIP-27 content parsing| | nostr-extras.test.ts | Tier 3 re-export surface verification | | nostr-profile-http.test.ts | HTTP API endpoints including identity/relay-info routes | | nip46-signer.test.ts | NIP-46 client secret encode/decode, bunker URL parsing, request queue | | nip46-doctor.test.ts | NIP-46 preflight diagnostics (config validation, report formatting) | | nip46-enroll.test.ts | NIP-46 enrollment flow (secret gen, URL parsing, progress) | | nip46-cutover.test.ts | NIP-46 cutover migration (preconditions, rollback) | | nip46-status.test.ts | Custody status resolution and formatting | | config-schema.test.ts | Config validation including NIP-46 fields | | types.test.ts | Account resolution including NIP-46 config, env/file/exec secret sources |

Manual Test

  1. Start the gateway with Nostr configured
  2. Open Damus, Amethyst, or another Nostr client
  3. Send a DM to your bot's npub
  4. Verify the bot responds

Security Notes

  • Private keys are never logged
  • Event signatures are verified before processing
  • Use environment variables for keys, never commit to config files
  • Consider using allowlist mode in production
  • NIP-98 and BUD-11 Blossom tokens are signed with the bus's key — scope them to specific URLs/servers and hashes
  • NIP-44 conversation keys are derived from the bus's secret key
  • HTTP mutation endpoints (PUT, POST, DELETE) are restricted to loopback addresses
  • NIP-46: The client secret (nip46Secret) is distinct from the identity key — store it via NOSTR_NIP46_SECRET env var or a SecretRef (env, file, exec), never in raw config
  • NIP-46 cutover: Use performCutover() for safe migration — it auto-rolls back if verification fails

Troubleshooting

Bot not receiving messages

  1. Verify private key (or NIP-46 bunker URL + secret) is correctly configured
  2. Check relay connectivity
  3. Ensure enabled is not set to false
  4. Check the bot's public key matches what you're sending to

NIP-46 connection failing

  1. Verify the bunker is online and reachable
  2. Check nip46BunkerUrl is a valid bunker:// URL or user@domain
  3. Verify NOSTR_NIP46_SECRET env var is set (64-char hex)
  4. Check relay connectivity to the signer relays
  5. Increase nip46ConnectionTimeoutMs if the bunker is slow to respond
  6. Check logs for NIP-46 auth URL — the bunker may require user approval

Messages not being delivered

  1. Check relay URLs are correct (must use wss://)
  2. Verify relays are online and accepting connections
  3. Check for rate limiting (reduce message frequency)

Docker: Plugin not loading

  1. Verify the volume mount path is correct and readable
  2. Check plugins.load.paths points to the right directory
  3. Run openclaw plugins list to see discovered plugins
  4. Check container logs: docker compose logs openclaw-gateway

NIP-05 resolution failing

  1. The target domain must serve /.well-known/nostr.json
  2. Check for CORS issues if resolving from a browser context
  3. Results are cached for 5 minutes — wait or restart to retry

License

MIT