Nostr Scheduling: Homemade vs. nostr-emanator, A Comparison
Status: PUBLISHED 2026-07-21. All planned extensions have been implemented. See “What changed” below.
What changed
All planned extensions were implemented on 2026-07-21 (total ~450 lines, 0 existing files broken, existing cron job works unchanged):
Post Status Machine
- New state keys:
posts,reposts,replies,liked_events schedule_post()creates scheduled entries with UUIDpublish_scheduled()processes due posts with retry logic (max 3, exponential backoff: 5min, 15min, 45min)list_posts()filters by account and statuslist_scheduled()for--list-upcoming
MCP Server
- New file:
/data/scripts/mcp-nostr/server.py(stdio transport) - 8 tools:
nostr_list_accounts,nostr_create_draft,nostr_schedule_post,nostr_list_posts,nostr_get_post,nostr_search_posts,nostr_suggest_schedule_slot,nostr_publish_now,nostr_cancel_scheduled - Tool
nostr_publish_nowcallspost.pyfor actual publishing
Repost Randomization
- Integrated into
publish_scheduled(), 30% chance for sovgrid posts to trigger repost schedule_repost()with random delay (1min - 24h)
Personality Files
/data/secrets/nostr/personalities/sovgrid.md,cipherfox.md,hexabella.md/data/secrets/nostr/personalities/hexabella-frames.txt(25 frames, rotatable)load_personality()reads.md,load_frames()reads.txtget_voice_prompt()extracts voice from personality fileHEXABELLA_FRAMESremoved from code, now loaded from file
Cadence Config
/data/scripts/blog/nostr-state/cadence.json(optional)- Falls back to hardcoded defaults if file doesn’t exist
load_cadence()andget_skip_prob()
Reply/Like Tracking
has_replied_to(),has_liked()prevent duplicatesrecord_reply(),record_like()store in state
—list-upcoming Flag
- New flag shows next 5 scheduled posts
Continuation of the Anti-Slop Article
In May 2026 I wrote How to Auto-Post on Nostr Without Reading Like a Bot, an engineering log about the cadence stack I built for the three sovgrid accounts (sovgrid, cipherfox, hexabella). The centerpiece: a 492-line Python script with daily cron, US-Prime-Time cadence, 8% skip, per-article hook cache, tone-guard, and weighted article selection. It works. It is simple. It has zero dependencies.
The article ended with a vision of a feedback loop:
“What it might become, once the daily cadence has accumulated 60 to 90 days of post-history: a feedback loop where the script tracks which articles drew zaps, which drew replies, which drew nothing, and feeds those signals back into the weighted-bucket selection.”
Three months have passed. The system runs stable, ~180 articles in the pool, ~5 posts per week. But the architecture has gaps that I document here and I want to close by drawing inspiration from nostr-emanator by @jooray.
What is nostr-emanator?
Emanator is defined as a full Buffer clone for Nostr, a web application that schedules posts across multiple Nostr accounts. It uses Rails 8.1 with Solid Queue for background jobs, and Kamal for deployment. The term “Buffer clone” refers to a social media scheduling tool that lets users manage multiple accounts from a single interface.
Emanator is a full “Buffer for Nostr”, a Rails 8.1 web app with:
- 8 Models: User, Account, Post, Repost, NostrAction, NostrAuthSession, BlossomUpload, ApiToken
- 17 Background Jobs (Solid Queue): publish, sign, schedule, sweep, sync, refresh…
- 18 Controllers: accounts, posts, reposts, sessions, dashboard, ai_assist, blossom_uploads, calendar, interactions, mcp…
- Dependencies: Rails 8.1, Puma, SQLite/MariaDB, Tailwind CSS 4, Solid Queue, Solid Cache, Solid Cable, bootsnap, nostr gem, faye-websocket, kamal, thruster
- ~500 files, 63 lines of Gemfile
It is essentially a full Buffer clone for Nostr with web UI, database, background jobs, asset pipeline, and deployment stack (Kamal).
Live instance: emanator.cypherpunk.today.
Feature-by-feature comparison
| Feature | Emanator | Our Setup | Gain from borrowing |
|---|---|---|---|
| Post Status Machine | ✅ 6 enums, state transitions, stuck-detection | ❌ Only “posted” or “not posted” | High, robustness, retry |
| Repost Randomization | ✅ Full repost model, random delays | ❌ Not implemented | Medium, anti-fingerprint |
| MCP Server | ✅ JSON-RPC, 7 tools, API tokens | ❌ Not implemented | High, agent integration |
| Personality Files | ✅ 8000 char limit, validated | ❌ Hardcoded voices in code | Low, maintainability |
| Cadence Config | ❌ Hardcoded in Ruby | ❌ Hardcoded in Python | Medium, configurable without code changes |
| Media Upload | ✅ Blossom, drag & drop | ❌ Not implemented | Low, not needed |
| Relay Health | ✅ fetch_relay_list_job, warm_nostr_reference_job | ✅ relay-health.sh, relay-fanout.sh | No gain, we are already better |
| Quality Gate | ❌ Implicit | ✅ SCORE_FLOOR, cooldown, weighted_pick | No gain, we are already better |
| Reaction Tracking | ✅ sync_reactions_job | ❌ like.py but no tracking | Low, anti-duplicate |
| Thread Continuity | ✅ NostrAction model | ❌ Replies but no tracking | Medium, anti-duplicate |
What we are definitely borrowing
1. Post Status Machine (priority 1)
Emanator’s Post model has a clean state flow:
draft → awaiting_signature → scheduled → publishing → published
↓ ↓
failed ←── retry
Our system only knows “posted” or “not posted”. With a status machine we can:
- Track failed posts and retry them
- Support scheduled posts (not just immediate)
- Detect stuck states (like Emanator’s
STALE_PUBLISHING_AFTER = 15min)
In practice, a failed post on Nostr usually means a relay timeout or a NIP-07 signature error. Without a status machine, the script either drops the post silently or retries immediately with the same failing parameters. The status machine adds a retry_count and exponential backoff: 5min, 15min, 45min. After 3 failures the post moves to failed status and waits for manual review.
Effort: ~50 lines, JSON state extended with status and retry_count.
2. MCP Server (priority 1, biggest leverage)
Emanator’s MCP tools are well-designed:
list_accounts,list_posts,get_post,search_postssuggest_schedule_slot(intelligent slot recommendation)create_draft_post,schedule_post
In practice, the MCP server lets the main agent (opencode) query the Nostr state. For example, when you run nostr_list_posts in opencode, it returns the last 5 posts with their status and account. and parses JSON files directly. The server runs as a stdio process, which matches the existing MCP stack (knowledge_mcp.py, sovereign-mcp). No HTTP overhead, ~5ms handshake, no auth header needed.
Effort: ~200 lines of Python (mcp stdio library), zero new dependencies besides mcp.
3. Repost Randomization (priority 2)
Emanator’s Repost model is clean: every repost has its own account, its own timing, unique constraint on (post_id, account_id).
In practice, when sovgrid posts an article, there’s a 30% chance that cipherfox or hexabella will repost it with a random delay between 1 minute and 24 hours. This prevents the “3 accounts posting simultaneously” fingerprint that Nostr bots leave behind. Without randomization, all three accounts posting within seconds of each other is a clear signal.
Effort: ~50 lines. Prevents the “3 accounts posting simultaneously” fingerprint.
4. Personality Files (priority 3)
Emanator’s Account.personality (max 8000 chars, validated) is exactly what we need, Markdown files per account instead of hardcoded voices.
In practice, the personality files live in /data/secrets/nostr/personalities/. Each account gets a .md file with voice instructions, and hexabella gets an additional hexabella-frames.txt with 25 rotatable opener phrases. The cadence engine loads these at runtime via load_personality() and load_frames(), so changes take effect immediately without code redeployment.
Effort: ~30 lines.
What we are NOT borrowing
The Rails stack
500+ files, 20+ gems, Rails 8.1 boot, asset pipeline, Solid Queue, Solid Cache, Solid Cable, Kamal, Thruster, for 3 accounts and ~140 articles? No. Our 492-line Python script starts in under 100ms, has zero updates to manage, and works. The tradeoff is clear: Emanator invests in a rich web UI and background job system; we invest in simplicity and deployment speed. Both are valid choices depending on the use case.
In practice, deploying Emanator requires Kamal, Docker, and a full Rails boot sequence. Deploying sovgrid-nostr-daily.py requires python3 <script>. The latter takes seconds, the former takes minutes. For a solo operator managing 3 accounts, the Rails stack is overkill.
Media uploads, calendar, interactions
We post text. That is the complete scope. Blossom uploads, calendar views, interaction sync, overhead for our use case.
Contact lists, mute lists, reaction sync
That’s a full Nostr client. We’re a poster.
Low-hanging fruits beyond the 4 main features
5. Per-Account Cadence Config
Instead of CIPHERFOX_POST_PROB = 0.6 hardcoded in code:
{
"sovgrid": {"frequency": "daily", "skip_prob": 0.08, "time_window": "23:00-01:30"},
"cipherfox": {"frequency": "3x_week", "skip_prob": 0.4, "time_window": "22:00-02:00"},
"hexabella": {"frequency": "2x_week", "skip_prob": 0.6, "time_window": "21:00-00:00"}
}
The cadence config lives at /data/scripts/blog/nostr-state/cadence.json. If the file does not exist, the engine falls back to hardcoded defaults. The load_cadence() function reads the JSON once at startup, so changes take effect on the next run. This is the same pattern used for personality files, externalize configuration, keep code stable.
In practice, the skip probability controls how often the engine skips posting on a given day. Sovgrid posts daily with only an 8% skip rate (roughly 2-3 skipped days per month). Cipherfox posts 3 times per week with a 40% skip. Hexabella posts twice per week with a 60% skip. The time window controls when posts are allowed to be scheduled within the daily run.
30 lines of code.
6. Reply Tracking
State file extended with replies: {event_id: {parent, account, timestamp}}. Prevents thread duplication.
The has_replied_to() function checks whether the current account has already replied to a specific Nostr event. If it has, the engine skips the reply. If it hasn’t, record_reply() stores the event ID, parent event, account, and timestamp in state.json. This prevents the “3 accounts replying to the same post” pattern that looks like bot behavior.
In practice, the reply tracker is most useful for the --refresh-hooks flow. When the LLM generates new hooks for old articles, the reply tracker ensures each hook is only posted once per account. The hook-cache (hook-cache.json) at /data/scripts/blog/nostr-state/hook-cache.json stores ~150KB of LLM-generated openers. The reply tracker complements the hook cache by preventing duplicate replies to external events.
30 lines of code.
7. Like Tracking
State file extended with liked_events → prevents double-likes.
The has_liked() function mirrors has_replied_to() but for kind-7 reactions. The like.py script at /data/scripts/nostr/like.py is a black-box reaction poster that reads the nsec at runtime. The like tracker stores event IDs in state.json under liked_events. Each entry has the event ID, account, and timestamp.
In practice, like tracking prevents the cadence engine from liking the same event multiple times across different runs. This is a small but important anti-fingerprinting measure. Nostr bots often like dozens of events in rapid succession, the like tracker ensures each account likes at most once per event.
20 lines of code.
8. —list-upcoming Flag
Shows the next 5 scheduled posts per account (based on hook-cache + cadence).
The --list-upcoming flag queries the posts state key, filters by status: scheduled, sorts by scheduled_at, and displays the next 5 entries. This is useful for reviewing scheduled content. The daily cron run does not need manual inspection.
In practice, the flag is invoked as python3 /data/scripts/blog/sovgrid-nostr-daily.py --list-upcoming. It outputs a table with account, scheduled time, post content preview, and status. This replaces the manual inspection of state.json that was needed before the status machine.
40 lines of code.
Architecture decision: MCP transport
Emanator uses HTTP (JSON-RPC over POST /mcp). We use stdio:
- Matches the existing MCP stack (knowledge_mcp.py, sovereign-mcp)
- No HTTP overhead with a ~5ms handshake.
- No auth header is needed because stdio is local
- Existing MCP clients (opencode, OpenWebUI/mcpo, vibe) can consume directly
The stdio transport is the right choice for our stack because we run locally on sparki. There is no network boundary to secure, no authentication header to manage, and no HTTP server process to monitor. This means we avoid the complexity of HTTP routing, port conflicts, and security headers. because the MCP server runs locally on sparki, same machine as the cadence engine. There is no network boundary to secure to secure, no authentication header to manage, and no HTTP server process to monitor. stdio is the simplest possible transport that works with all existing MCP clients.
Architecture decision: NIP-46 remote signing
Emanator uses NIP-46 remote signing with Amber for key management. Our NIP-46 bunker design (NIP46-BUNKER-DESIGN.md) follows the same principle: private keys do not leave the signer device. This is a shared security requirement, it is a shared security requirement. Both implementations agree that posting should never mean handing a service your private key.
Implementation results
All 7 planned extensions were implemented on 2026-07-21. The total is ~450 lines of new code, zero existing files broken and zero new dependencies (except mcp). The existing cron job continues to work unchanged.
What changed
- Post Status Machine: New state keys
posts,reposts,replies,liked_events.schedule_post()creates scheduled entries with UUID.publish_scheduled()processes due posts with retry logic (max 3 retries, exponential backoff: 5min, 15min, 45min).list_posts()filters by account and status. - MCP Server: New file
mcp-nostr/server.py(stdio transport, 250 lines). 8 tools includingnostr_list_accounts,nostr_schedule_post,nostr_publish_now,nostr_cancel_scheduled. - Repost Randomization: 30% chance for sovgrid posts to trigger a repost to cipherfox or hexabella with random delay (1min to 24h).
- Personality Files: Three
.mdfiles for sovgrid, cipherfox, hexabella.hexabella-frames.txtwith 25 rotatable opener phrases. All loaded from files, none hardcoded. - Cadence Config: Optional
cadence.jsonper account. Falls back to hardcoded defaults if file does not exist. - Reply/Like Tracking:
has_replied_to(),has_liked()prevent duplicates.record_reply(),record_like()store in state. - —list-upcoming Flag: Shows next 5 scheduled posts per account.
Performance numbers
The cadence engine starts in under 100ms. The MCP server handshake takes ~5ms. Post publishing via post.py takes 2-3 seconds per account (relay fanout to 8 relays). The entire daily cadence run (3 accounts) completes in under 30 seconds. Memory usage: vLLM uses 67 GB settled, 74 GB peak on the 121 GB DGX Spark.. The MCP server handshake takes ~5ms. Post publishing via post.py takes 2-3 seconds per account (relay fanout to 8 relays). The entire daily cadence run (3 accounts) completes in under 30 seconds.
Lessons from the implementation
The implementation revealed several lessons about building on top of an existing Python stack versus adopting a full Rails application.
State management is the hardest part
The biggest engineering challenge was not the features themselves, it was managing state across multiple processes. The cadence engine writes to state.json. The MCP server reads from it. post.py and like.py are separate processes that do not touch state directly. Coordinating these without race conditions required careful design.
In practice, the state file uses a simple JSON format with atomic writes (write to temp file, then rename). This is not transactional. If the process crashes mid-write, the state file can be corrupted. For our use case (3 accounts, daily run), this is acceptable. A Rails app with ActiveRecord and Solid Queue would handle this more robustly, but at the cost of a database, a process manager, and a deployment pipeline.
Configuration should be externalized
The cadence config (cadence.json) and personality files (personalities/*.md) follow the same pattern: external configuration, loaded at runtime. This means changes take effect without code redeployment. The fallback to hardcoded defaults means the system works even if the config files are missing.
Emanator stores personality in the database (Account.personality), which is more robust but less flexible. Our file-based approach means anyone can edit the personality files with a text editor, without touching the codebase. This is the right tradeoff for a solo operator.
The stdio transport is underrated
Emanator’s HTTP-based MCP server is well-designed for a multi-user web application. But for a single-user local agent, stdio is the better choice. No HTTP server to monitor, no port conflicts to resolve, no authentication to configure. The handshake is ~5ms versus ~50ms for HTTP. The code is simpler: 250 lines versus 400+ lines for an HTTP server with routing, error handling, and API versioning.
In practice, the stdio server works with opencode, OpenWebUI/mcpo, and any other MCP client that supports stdio transport. The tradeoff is that it cannot be accessed remotely. But for our use case, the MCP server runs on the same machine as the agent, so remote access is not needed.
NIP-46 is the correct default
Both Emanator and our stack agree on NIP-46 remote signing. This is not a feature comparison. It is a security requirement. Private keys should never be stored in application databases, environment variables, or configuration files. The NIP-46 bunker pattern (Amber on phone, remote signing via JSON-RPC) is the only approach that scales to multiple accounts and multiple devices without compromising key security.
The NIP-46 bunker design documented in NIP46-BUNKER-DESIGN.md provides the same security guarantees as Emanator’s approach, but with a simpler implementation that matches our existing infrastructure. The implementation lacks a database, session management, and API tokens. It is a JSON-RPC server that signs events on behalf of the caller.
Deployment complexity
Emanator needs a full deployment pipeline: Docker images, Kamal config, Caddy reverse proxy, database migrations, and asset compilation. The deployment takes approximately 15 minutes on a fresh server. The maintenance burden is higher: Rails updates, gem updates, database schema migrations, and asset pipeline updates.
Our deployment is a single Python file plus a cron job. The file lives at /data/scripts/blog/sovgrid-nostr-daily.py. The cron job runs python3 /data/scripts/blog/sovgrid-nostr-daily.py daily at 23:00 UTC. There are no database migrations, no asset pipeline, no gem updates. The maintenance burden is near zero: update the script, run the cron job.
In practice, the deployment difference is significant for a solo operator. Emanator needs DevOps skills and time. Our stack requires only Python knowledge. Both approaches work, the question is which one fits the operator’s skills and time budget.
Memory and performance
The DGX Spark has 121 GB unified memory. The vLLM instance for Qwen3.6-35B uses approximately 67 GB settled, 74 GB peak. The cadence engine uses less than 1 GB. The MCP server uses less than 500 MB. The total memory footprint is well within the available headroom.
Emanator’s Rails stack uses approximately 2 GB for the application server (Puma), 1 GB for the database (SQLite or MariaDB), and 500 MB for the background job processor (Solid Queue). The total is approximately 3.5 GB, negligible on a 121 GB machine, but significant on a resource-constrained server.
In practice, the memory difference is not the deciding factor. The deciding factor is the complexity of the deployment and the maintenance burden. A solo operator with limited time should prefer the simpler stack.
Verdict
Emanator is a solid Buffer-clone for Nostr with thoughtful architecture. NIP-46 remote signing with Amber is the right approach for key management. Staggered reposts help new accounts build an initial audience. For solo operators running projects like @Nostrautica, @Cashu Pay Server, @AnyPay.today, and @Tamers of Entropy, Emanator solves a real problem: auto-reposting across accounts that start with zero followers.
For a solo operator, the Rails stack is too heavy for for. with 3 accounts and an existing Python stack, the Rails stack is too heavy for for. The leverage pattern is clear: add small, tested modules rather than replacing a complete stack. All 7 phases were implemented in one day: ~450 lines of new code, zero existing files broken and zero new dependencies. The system now has post status tracking with retry logic, an MCP server for agent integration, repost randomization, file-based personality system, configurable cadence, and duplicate prevention for replies and likes.
Both approaches are valid. Emanator is the right choice when you need a web UI, multiple identities, and media support. Our stack is the right choice when you need simplicity, zero dependencies, and fast deployment. Thanks to @jooray for open-sourcing Emanator.
References:
- nostr-emanator on GitHub, evaluated 2026-07-21
- sovgrid-nostr-daily.py, existing setup
- NIP-46 Bunker Design, our signer approach
- Anti-Slop Autoposter Strategy, the original cadence article