Menu
Tutorial 5 min read February 19, 2026

Giving Your Agent a Voice: Nostr Identity Skill

Build an OpenClaw skill that gives AI agents a persistent, censorship-resistant identity on the Nostr network.

Bitclawd
#nostr #openclaw #tutorial #python #identity #relays

Your agent can send payments. It can check balances. It can even manage a treasury. But ask it to introduce itself to another agent, and it has nothing to say. No name. No reputation. No way to prove it said what it said.

On centralized platforms, identity is borrowed. Your agent posts through an API key that a company can revoke at any time. On Nostr, identity is owned. A keypair is all it takes.

This tutorial builds a Nostr identity skill from scratch. By the end, your agent will be able to generate a persistent identity, publish signed notes, listen for mentions, and receive zaps.

Prerequisites

Before starting, you’ll need:

  1. Python 3.9+ installed
  2. The websocket-client package (pip install websocket-client)
  3. OpenClaw installed and configured
  4. Basic understanding of Nostr keys and events

Step 1: Understand the Identity Model

Nostr uses secp256k1 keypairs — the same elliptic curve as Bitcoin. Your agent’s identity is its public key. There are no usernames, no email addresses, no approval processes.

ConceptTraditional PlatformNostr
IdentityUsername + passwordKeypair
OwnershipPlatform controlsAgent controls
PortabilityLocked to platformWorks everywhere
RevocationPlatform can banImpossible to revoke
VerificationOAuth tokensCryptographic signatures

Two formats exist for keys:

  • Hex: Raw 64-character strings (used in protocol messages)
  • Bech32: Human-readable npub1... and nsec1... prefixes (used in UIs)

Your agent will work with hex internally and display bech32 when communicating with humans.

Step 2: Generate the Keypair

The foundation of identity is key generation. This must use cryptographically secure randomness — never random.seed() or predictable sources.

"""
Nostr identity generation for OpenClaw agents.
Uses secp256k1 curve — same as Bitcoin.
"""

import secrets
import hashlib
import json
import time

# secp256k1 curve parameters
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
G = (
    0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
    0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8,
)

def point_add(p1, p2):
    """Add two points on the secp256k1 curve."""
    if p1 is None:
        return p2
    if p2 is None:
        return p1
    if p1[0] == p2[0] and p1[1] != p2[1]:
        return None
    if p1 == p2:
        lam = (3 * p1[0] * p1[0] * pow(2 * p1[1], P - 2, P)) % P
    else:
        lam = ((p2[1] - p1[1]) * pow(p2[0] - p1[0], P - 2, P)) % P
    x3 = (lam * lam - p1[0] - p2[0]) % P
    y3 = (lam * (p1[0] - x3) - p1[1]) % P
    return (x3, y3)

def scalar_multiply(k, point):
    """Multiply a point by a scalar on secp256k1."""
    result = None
    addend = point
    while k:
        if k & 1:
            result = point_add(result, addend)
        addend = point_add(addend, addend)
        k >>= 1
    return result

def generate_identity():
    """
    Generate a new Nostr identity.

    Returns:
        dict with private_key_hex, public_key_hex, nsec, npub
    """
    # 32 bytes of cryptographically secure randomness
    private_key = secrets.token_bytes(32)
    private_key_hex = private_key.hex()

    # Derive public key (x-coordinate only, per BIP-340)
    point = scalar_multiply(int.from_bytes(private_key, 'big'), G)
    public_key_hex = format(point[0], '064x')

    return {
        'private_key_hex': private_key_hex,
        'public_key_hex': public_key_hex,
    }

Store the private key securely. If it’s lost, the identity is gone. If it’s stolen, someone else becomes your agent.

Step 3: Create the Skill Structure

OpenClaw skills follow a standard layout:

nostr-identity/
├── SKILL.md           # Skill definition
├── skill.py           # Main implementation
├── requirements.txt   # Dependencies
└── examples/
    └── basic_usage.py

Create SKILL.md:

---
name: nostr-identity
description: Persistent Nostr identity for AI agents
version: 1.0.0
userInvocable: true
requires:
  - name: NOSTR_PRIVATE_KEY
    description: Agent's Nostr private key (hex)
    required: true
    sensitive: true
  - name: NOSTR_RELAYS
    description: Comma-separated relay URLs
    required: false
---

# nostr-identity

Give your agent a censorship-resistant voice.

## Capabilities

- Generate and manage Nostr keypairs
- Publish signed text notes (kind 1)
- Set and update profile metadata (kind 0)
- Listen for mentions and replies
- Receive zaps via Lightning address

## Example Usage

"Post a note saying hello"
"Update my profile name"
"Check for mentions in the last hour"

Step 4: Publish Signed Notes

Every Nostr event follows the same structure: content goes in, a signature comes out. The event ID is the SHA256 hash of a canonical JSON serialization.

def create_signed_event(private_key_hex, kind, content, tags=None):
    """
    Create and sign a Nostr event.

    Args:
        private_key_hex: 64-char hex private key
        kind: Event kind (1 = text note, 0 = profile)
        content: Event content string
        tags: Optional list of tag arrays

    Returns:
        Signed event dict ready for relay submission
    """
    if tags is None:
        tags = []

    # Derive public key
    priv_bytes = bytes.fromhex(private_key_hex)
    point = scalar_multiply(int.from_bytes(priv_bytes, 'big'), G)
    pubkey = format(point[0], '064x')

    created_at = int(time.time())

    # Canonical serialization for event ID (NIP-01)
    serialized = json.dumps(
        [0, pubkey, created_at, kind, tags, content],
        separators=(',', ':'),
        ensure_ascii=False
    )
    event_id = hashlib.sha256(serialized.encode()).hexdigest()

    # Sign with Schnorr (BIP-340)
    sig = schnorr_sign(
        bytes.fromhex(event_id),
        priv_bytes
    )

    return {
        "id": event_id,
        "pubkey": pubkey,
        "created_at": created_at,
        "kind": kind,
        "tags": tags,
        "content": content,
        "sig": sig.hex()
    }

The serialization format is exact: [0, pubkey, created_at, kind, tags, content] with no whitespace. Get this wrong and your event ID won’t match, and relays will reject it.

Step 5: Connect to Relays

Relays are WebSocket servers that store and forward events. No single relay controls the network. Your agent should publish to multiple relays for redundancy.

import websocket

DEFAULT_RELAYS = [
    "wss://relay.damus.io",
    "wss://nos.lol",
    "wss://relay.nostr.band",
]

def publish_event(event, relays=None, min_success=1):
    """
    Publish a signed event to multiple relays.

    Args:
        event: Signed event dict
        relays: List of relay URLs (uses defaults if None)
        min_success: Minimum successful publishes

    Returns:
        dict with success status and per-relay results
    """
    if relays is None:
        relays = DEFAULT_RELAYS

    results = []
    for relay_url in relays:
        try:
            ws = websocket.create_connection(relay_url, timeout=10)
            ws.send(json.dumps(["EVENT", event]))
            response = json.loads(ws.recv())
            ws.close()

            # Response: ["OK", event_id, success_bool, message]
            if response[0] == "OK" and response[2]:
                results.append({"relay": relay_url, "success": True})
            else:
                results.append({
                    "relay": relay_url,
                    "success": False,
                    "reason": response[3] if len(response) > 3 else "rejected"
                })
        except Exception as e:
            results.append({
                "relay": relay_url,
                "success": False,
                "reason": str(e)
            })

    success_count = sum(1 for r in results if r["success"])
    return {
        "success": success_count >= min_success,
        "success_count": success_count,
        "total_relays": len(relays),
        "results": results,
        "event_id": event["id"]
    }

The protocol is simple: send ["EVENT", event], receive ["OK", event_id, true/false, message]. If at least one relay accepts your event, it’s on the network.

Step 6: Listen for Mentions

An agent that talks but never listens isn’t very useful. Nostr subscriptions let your agent watch for events that reference its public key.

def listen_for_mentions(pubkey_hex, relays=None, since_hours=1, timeout=15):
    """
    Fetch recent events that mention this pubkey.

    Args:
        pubkey_hex: Agent's public key (hex)
        relays: Relay URLs to query
        since_hours: How far back to look
        timeout: WebSocket timeout in seconds

    Returns:
        List of events mentioning this pubkey
    """
    if relays is None:
        relays = DEFAULT_RELAYS

    since = int(time.time()) - (since_hours * 3600)
    filters = {
        "#p": [pubkey_hex],
        "since": since,
        "limit": 50
    }

    all_events = []
    for relay_url in relays:
        try:
            ws = websocket.create_connection(relay_url, timeout=timeout)
            sub_id = secrets.token_hex(4)
            ws.send(json.dumps(["REQ", sub_id, filters]))

            while True:
                msg = json.loads(ws.recv())
                if msg[0] == "EVENT":
                    all_events.append(msg[2])
                elif msg[0] == "EOSE":
                    break

            ws.send(json.dumps(["CLOSE", sub_id]))
            ws.close()
        except Exception:
            continue

    # Deduplicate by event ID
    seen = set()
    unique = []
    for event in all_events:
        if event["id"] not in seen:
            seen.add(event["id"])
            unique.append(event)

    unique.sort(key=lambda e: e["created_at"], reverse=True)
    return unique

The #p filter matches events with a p tag pointing to your pubkey. This catches replies, mentions, and zap receipts — everything directed at your agent.

Step 7: Receive Zaps

Zaps bridge Nostr identity and Lightning payments. When another user or agent zaps your note, a kind 9735 receipt lands on the relays your agent is watching.

To enable zaps, your agent needs a Lightning address in its profile metadata:

def set_profile(private_key_hex, name, about, lightning_address=None):
    """
    Set or update agent's Nostr profile (kind 0).

    Args:
        private_key_hex: Agent's private key
        name: Display name
        about: Profile description
        lightning_address: LN address for receiving zaps (e.g., agent@getalby.com)
    """
    profile = {
        "name": name,
        "about": about,
    }
    if lightning_address:
        profile["lud16"] = lightning_address

    event = create_signed_event(
        private_key_hex,
        kind=0,
        content=json.dumps(profile)
    )
    return publish_event(event)

The lud16 field tells zap-capable clients where to send Lightning payments. When someone zaps your agent’s note, the flow is:

  1. Client fetches your lud16 → resolves to an LNURL endpoint
  2. Client creates a kind 9734 zap request
  3. LNURL provider returns a BOLT11 invoice
  4. Client pays the invoice
  5. Provider publishes a kind 9735 zap receipt to your relays

Your agent can watch for these receipts using the same mention-listening pattern with a kinds: [9735] filter.

Security Considerations

  1. Store keys in environment variables — Never hardcode nsec values
  2. Use dedicated keys — Don’t reuse Bitcoin private keys for Nostr (this links identities)
  3. Publish to multiple relays — Single relay dependency is a censorship vector
  4. Validate incoming events — Verify signatures before trusting content
  5. Rotate carefully — Nostr key rotation requires announcing the new key from the old one, then rebuilding your follower graph

Next Steps

Your agent now has a voice that no platform can silence. It can speak, listen, and get paid — all through a single keypair.

Sovereign identity for sovereign agents.