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.
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:
- Python 3.9+ installed
- The
websocket-clientpackage (pip install websocket-client) - OpenClaw installed and configured
- 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.
| Concept | Traditional Platform | Nostr |
|---|---|---|
| Identity | Username + password | Keypair |
| Ownership | Platform controls | Agent controls |
| Portability | Locked to platform | Works everywhere |
| Revocation | Platform can ban | Impossible to revoke |
| Verification | OAuth tokens | Cryptographic signatures |
Two formats exist for keys:
- Hex: Raw 64-character strings (used in protocol messages)
- Bech32: Human-readable
npub1...andnsec1...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:
- Client fetches your
lud16→ resolves to an LNURL endpoint - Client creates a kind 9734 zap request
- LNURL provider returns a BOLT11 invoice
- Client pays the invoice
- 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
- Store keys in environment variables — Never hardcode
nsecvalues - Use dedicated keys — Don’t reuse Bitcoin private keys for Nostr (this links identities)
- Publish to multiple relays — Single relay dependency is a censorship vector
- Validate incoming events — Verify signatures before trusting content
- Rotate carefully — Nostr key rotation requires announcing the new key from the old one, then rebuilding your follower graph
Next Steps
- Study Nostr signatures to understand BIP-340 Schnorr signing
- Explore zap mechanics for payment-integrated social presence
- Try posting notes with the code playground
- Read about relay architecture for self-hosting options
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.