Nostr (nostr)
Nostr is a decentralized protocol, not a company: there is no developer portal, no OAuth app and no app review. A “channel” is a secp256k1 keypair plus a relay list. Rustiz accepts the 64-character hex private key, signs a kind-1 text note with BIP-340 Schnorr, and broadcasts it to a fixed set of public relays over WebSockets.
- Rust source:
crates/rustiz-providers/src/nostr.rs - Upstream port:
nostr.provider.ts - Editor: Normal
- Connection mode:
customFields - Tool tip: “Make sure you private a HEX key of your Nostr private key, you can get it from websites like iris.to” (the upstream typo is preserved in the Rust string).
Overview
| Property | Value |
|---|---|
| Identifier | nostr |
| Display name | Nostr |
| Connection mode | customFields (hex private key) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Advertised as no: supports_comments is left false, even though comment() is implemented. The engine truncates extra thread entries and MCP validation reports “does not support thread replies”. |
| Pending / async publishing | No. supports_pending = false; post() broadcasts and returns Completed |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 5 |
Character limit (max_length) | 100000 characters, hard-coded |
| Scopes | None |
| Redirect URI | None |
| Relay default | 5 public relays (see below) |
Server Environment Variables
required_env() returns nothing:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&[]
}
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
JWT_SECRET | Yes in practice | empty | Signs the HS256 “access token” JWT that wraps the Nostr private key. If it changes, every Nostr channel must reconnect. |
NOSTR_RELAYS | No | 5 built-in relays | Comma-separated relay URLs. The code comment says it exists so tests can point at a local WebSocket server, but it is a real runtime override in release builds. |
DISABLE_SSRF_PROTECTION | No | unset | Not read by Nostr itself. Relay connections use tokio-tungstenite directly, not the shared SSRF-safe HTTP client, so private relays are reachable without this switch. |
There is no NOSTR_BASE_URL. Relays are WebSocket URLs, configured by
NOSTR_RELAYS or the constant list.
Default relays, in order:
wss://nos.lol
wss://relay.damus.io
wss://relay.snort.social
wss://temp.iris.to
wss://vault.iris.to
rustiz providers --status reports nostr as configured with no environment
at all, because credentials arrive per channel. It cannot detect a missing
JWT_SECRET; the provider still signs with an empty HMAC key (insecure but
functional).
Step-by-Step Developer App Setup
There is no developer app, no portal and no review.
1. Obtain the hex private key
- Open a Nostr client that can export the key, for example https://iris.to or any NIP-19-aware client.
- Export the hex private key. It must be exactly 64 hexadecimal characters
(32 bytes), with no
0x, no spaces and no newline. - Do not paste an
nsec1…bech32 key. Rustiz does not decode bech32; see the gotchas below.
The corresponding public key is derived with BIP-340 Schnorr
(k256::schnorr::SigningKey). The public key is the channel’s internal id and
is also used as the authors filter when publishing.
2. Choose relays
The provider ships a fixed list of five public relays. Override it for a private relay, a test relay, or a region-specific set:
NOSTR_RELAYS="wss://relay.example.com,wss://nos.lol"
Notes:
- The list is split on
,, each entry trimmed; empty entries are skipped. - Every publish opens a new WebSocket per relay; more relays means slower publishing and more failure points.
- There is no NIP-65 relay-list discovery. The channel does not read the user’s kind-10002 relay list; it uses this server-side list for every channel.
3. Redirect URI and scopes
None. Nostr has no OAuth, no browser redirect and no scope model. The private
key is the credential. check_scopes() is never called.
4. Sandbox / testing vs production
- There is no sandbox and no app review. The same key works on mainnet relays immediately.
- For tests, run a local relay (for example
strfry,nostr-rs-relay, or a disposable public relay), setNOSTR_RELAYSto it, and use a throwaway key. Remember thatNOSTR_RELAYSis server-wide: changing it changes where every connected channel publishes. - Mainnet relays enforce their own policies (spam filters, proof-of-work, paid relays, rate limits, size limits). A relay may accept the WebSocket connection and silently drop the event; Rustiz does not surface per-relay rejections.
- A test note is public to anyone who can read the relay. Do not test with a production identity unless you intend to publish.
Connecting a Channel (CLI & MCP)
customFields key
| Key | Type | Default | Validation regex (exact string) | Notes |
|---|---|---|---|---|
password | password | — | /^.{3,}$/ | 64-character hex private key. The label is Nostr private key; the key name is password for historical reasons. |
The validation regex is misleading: it accepts any 3+ characters, including
nsec1…, 0x…, or a short string. Only authenticate() enforces 32 bytes of
hex, and it reports the failure as the generic Invalid credentials.
CLI
{ "password": "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa" }
rustiz channels connect nostr --fields ./nostr-key.json
# or stdin
printf '%s' '{"password":"67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa"}' \
| rustiz channels connect nostr --fields -
Immediate result:
{ "connected": true, "pending": false, "name": "Alice" }
The name comes from the kind-0 metadata event found on the first relay that
returns one; if none is found, it is No Name and the username is
nousername.
MCP connectChannelTool
{
"provider": "nostr",
"customFields": {
"password": "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa"
}
}
integrationSchema reports:
{
"type": "customFields",
"headless": true,
"requiresHuman": false
}
There is no follow-up. Call integrationList for the channel id.
Important: do not omit customFields
Without customFields, connect.rs falls through to the OAuth path.
Nostr::generate_auth_url() returns the random state as both url and
state, so the agent gets a “login URL” that is not a URL and no channel is
created. This is the same legacy stub as Bluesky. Always send the key object.
Provider functions (triggerTool)
None. functions() is empty.
Post Settings & Media Validation
settings_schema()
Nostr does not override the trait default:
{ "type": "object", "properties": {}, "additionalProperties": true }
There are no provider-specific settings. __type is added by the engine.
Relays, tags and event kind are not configurable per post.
validate_settings()
Not overridden: every settings object is accepted.
check_validity()
Not overridden: no pre-flight media or length checks beyond max_length.
Media requirements
Nostr does not upload media. build_content() appends public URLs to the note
body:
#![allow(unused)]
fn main() {
let media_content = post.media.iter()
.map(|m| m.path.clone())
.collect::<Vec<_>>()
.join("\n\n");
if media_content.is_empty() {
post.message.clone()
} else {
format!("{}\n\n{}", post.message, media_content)
}
}
Consequences:
- Every media
pathmust be a public URL; a local path is published as text. - No alt text, thumbnail or MIME handling.
- No image/video upload, no NIP-94 file metadata, no NIP-92
imetatags. - Clients may render a URL preview if the URL is an image/video, but that is a client decision, not part of the event.
- An empty message with media produces content that starts with two newlines.
Threads
post() always sends tags: []. A NIP-01 kind-1 note with no e/p tags is
a root note.
comment() sends:
[
["e", "<reply_to>", "", "reply"],
["p", "<integration pubkey>"]
]
reply_toislast_comment_idwhen present, otherwise the rootpost_id.- The
ptag is the channel’s own pubkey (idisintegration.internal_id), not the parent author’s pubkey. It marks the author, not the person being replied to. - There is no
["e", root, "", "root"]tag. For a two-level thread this is usually fine; for deeper threads, clients that follow NIP-10 markers may attach the reply to the wrong root. supports_commentsisfalse, so the engine never callscomment()for a multi-entry post in practice. A manual provider-level call would work, but MCPvalidatePostToolrejects thread replies.
Plugs
No @Plug / @PostPlug methods. run_plug() and run_post_plug() return
Unsupported.
Implementation Notes & Gotchas
- Hex only, no
nsec.secret_key()splits the string into 2-character chunks and parses each withu8::from_str_radix(…, 16). It requires exactly 64 hex characters.nsec1…bech32,0x…, uppercaseNSEC, whitespace and newlines all fail. NIP-19 says clients should accept both hex andnsec; this port does not decode bech32. - Validation lets bad keys through.
custom_fields().validationis/^.{3,}$/, so the connect form accepts annsecor a 10-character string. The failure surfaces only after submission asInvalid credentials, becauseauthenticate()maps every key-derivation error to that message. - The access token is a JWT containing the private key.
jwt_sign()builds an HS256 JWT with payload{ "password": "<hex private key>", "iat": … }, base64url-encoded, and stores it as the integration’saccess_token. Anyone with database read access or theJWT_SECRETcan recover the Nostr private key. Treat the Rustiz database andJWT_SECRETas equivalent to the key itself. - The JWT has no expiry.
two_hundred_years()sets the metadataexpires_in, but the JWT payload has onlypasswordandiat; there is noexpclaim, andjwt_verify()does not check one. The token is valid untilJWT_SECRETchanges or the channel is deleted. - Changing
JWT_SECRETbreaks every channel.jwt_verify()uses the current secret; old tokens fail withInvalid Nostr token, please reconnect the channel. There is no key rotation or re-encryption path. refresh_token()is a no-op. It returns an empty token withexpires_in: 0. A 401/403 from a relay is not a token problem; it is usually a relay policy or a deleted event. Reconnect only if the JWT itself fails.- Publishing is fire-and-forget.
publish()opens a socket to each relay, sends["REQ", sub, {kinds:[1], authors:[pubkey]}]and then["EVENT", event], and waits up to 6 seconds for the relay to echo the event back. It returns the first echoed event id, or"".post()always returnsPostStatus::Completed, even when every relay failed. - Empty release URL. If no relay echoes the event,
post_idis empty andrelease_urlishttps://primal.net/e/. The post may still have been accepted by a relay that did not echo within 6 seconds. There is no reconciliation or “missing content” lookup. - Sequential relay I/O is slow.
publish()andquery_first()iterate the relay list one at a time. Each relay costs up to 10 s to connect plus up to 6 s waiting forEVENT/EOSE. With five relays, a publish can take up to ~80 s if every socket connects slowly and no relay echoes; authentication’s kind-0 lookup has the same worst case. HTTP request timeouts and the engine’s job timeouts must accommodate this. NOSTR_RELAYSis global. There is no per-channel relay list. Two channels cannot publish to different relays from one deployment.- No relay authentication. NIP-42
AUTHis not implemented. Paid or private relays that require authentication will close the connection or reject the event. - No NIP-65 / NIP-02 discovery. The provider does not read the user’s relay list or contact list. Profile lookup is a single kind-0 query per relay, and only the first event with a usable name wins.
- Profile lookup is best-effort.
find_relay_information()tries relays in order, parsescontentas JSON, and acceptsname,displayNameordisplay_name. If no relay answers, the channel name isNo Name, usernamenousername, picture empty. That is a valid connection; the profile can be fixed later by editing kind-0 metadata outside Rustiz and reconnecting. - Comment
ptag is self-referential. As noted above,["p", id]uses the integration’s own pubkey. A proper NIP-10 reply should tag the parent event’s author. This does not prevent publication, but it may suppress notifications to the person being replied to. - No root marker. Deeper replies lack the
rootmarker. Some clients reconstruct the thread from theechain; others show the reply as a top-level note. max_length = 100000is not a relay limit. Most public relays cap event size well below 100 KB (often 64 KB or less) and may reject large content with aNOTICEthat Rustiz ignores. Media URLs make long notes easy to hit that cap.- No per-relay error reporting.
publish_to_relay()returnsErr(())for connection/send failures and the caller ignores it. There is no log line per failed relay, no retry and no user-visible warning. - No rate limiting.
max_concurrent_job = 5is a concurrency cap, not a relay-aware rate limiter. Public relays commonly rate-limit or ban keys that publish in bursts. is_web3is false. Unlike Farcaster/Moltbook, Nostr is not marked web3, soconnect.rsdoes not reject it in the generic web3 branch.- Release URL is Primal-specific.
https://primal.net/e/{eventId}assumes Primal can resolve the event; other clients usenjump.me,habla.news, etc. The event id is the canonical identifier.
References
- NIP-01 (events, filters, relay messages): https://nips.nostr.com/1
- NIP-10 (reply/thread tags): https://nips.nostr.com/10
- NIP-19 (bech32
nsec/npub): https://nips.nostr.com/19 - BIP-340 (Schnorr signatures): https://bips.dev/340/
- Postiz Nostr settings: https://docs.postiz.com/public-api/providers/nostr
- Iris (key export): https://iris.to