Emanator is a full Buffer clone for Nostr with Rails, database, and background jobs. Our Python script is 492 lines, zero dependencies. Continuation of the Anti-Slop article: what we borrow, what we don't, and why Sovereign AI often means less code.

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

MCP Server

Repost Randomization

Personality Files

Cadence Config

Reply/Like Tracking

—list-upcoming Flag

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:

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

FeatureEmanatorOur SetupGain 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 implementedMedium, anti-fingerprint
MCP Server✅ JSON-RPC, 7 tools, API tokens❌ Not implementedHigh, agent integration
Personality Files✅ 8000 char limit, validated❌ Hardcoded voices in codeLow, maintainability
Cadence Config❌ Hardcoded in Ruby❌ Hardcoded in PythonMedium, configurable without code changes
Media Upload✅ Blossom, drag & drop❌ Not implementedLow, not needed
Relay Health✅ fetch_relay_list_job, warm_nostr_reference_job✅ relay-health.sh, relay-fanout.shNo gain, we are already better
Quality Gate❌ Implicit✅ SCORE_FLOOR, cooldown, weighted_pickNo gain, we are already better
Reaction Tracking✅ sync_reactions_job❌ like.py but no trackingLow, anti-duplicate
Thread Continuity✅ NostrAction model❌ Replies but no trackingMedium, 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:

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:

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:

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

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:

Was this worth it? Zap the article.

Value for value, no signup. Sats go straight to the writer.

sats zapped
zaps
Today 7d 30d All-time
Unique readers
Page views
All Article Insights →