How to Auto-Post on Nostr Without Reading Like a Bot
Three Nostr accounts opened on the same day cannot all post the same shape of content at the same time and expect to land as anything but a coordinated bot push. The technical work of running a self-hosted blog is the easy part. The harder problem is distribution without the marketing-spam fingerprint that most autoposters carry by default.
This article documents the cadence I built for the sovgrid brand account, the deliberate anti-pattern choices, and why three separate accounts (operator, AI co-host, brand) feed Nostr rather than one combined firehose.
Quick Take
- Five posts a week, Monday to Friday, around 20:00 CEST. Skip 8% of scheduled days at random. The point is that the cadence reads as built-by-a-human-with-a-cron, not built-by-a-marketing-team.
- Opener logic is per-article hook cache, not template substitution. Each article gets 3-5 Mistral-generated hooks that lead with a concrete number or fact from the actual content. Templates are bot-fingerprinted within two weeks.
- Pre-publish tone-guard rejects em-dashes, AI-slop adjectives (the “X is both powerful and flexible” class of words), aphorism couplets, and posts over 500 characters. Same gate as the blog’s quality scoring.
- Three accounts with distinct voice: cipherfox@sovgrid.org (skeptical operator, war stories), hexabella@sovgrid.org (AI co-host, warm translator), blog@sovgrid.org (brand, engineering log). Cross-account replies on launch posts to seed the network graph.
The four bot-fingerprints I am trying to avoid
Most autoposters look like autoposters within a week of operation. Four patterns give them away.
One: same opener-template every post. “If you want one place to start with X…” or “Today’s read: X.” When a reader sees three of these in a row, the autoposter is exposed. Fixed templates substituting article titles is the laziest possible cadence and the easiest to detect.
Two: hashtag-stuffing. Real human posts on Nostr typically carry 1-3 hashtags. Bots stack five to ten because the bot was optimized for SEO without understanding the social cost. The hashtag column at the end of a post is the most visible bot tell. This is why I cap at 2 hashtags per post: the SEO gain on Nostr is near zero (relays index full text regardless), and the reader signal cost is high.
Three: posting at exact cron-tick times. A post at exactly 18:00:00 UTC every weekday is a cron job. A post at 19:53 one day, 20:11 the next, 19:47 the day after, with the occasional skipped day, is plausibly a human running a cron job. The randomness is the disguise. Note that this does not fool sophisticated bot-detectors, which means the benefit is reader perception, not adversarial evasion.
Four: marketing speech. “Don’t miss this.” “Must-read.” “Essential breakdown.” This is corporate-tone language a self-hosted engineering log should never use. The reader can hear the marketing department typing.
Each of these is solvable individually. Solving them together is the build.
Architecture: where the logic lives
The cadence script runs from /data/scripts/blog/sovgrid-nostr-daily.py on the Sovereign Grid host. It is triggered by cron Monday through Friday at 17:50 UTC and sleeps a random 0 to 30 minutes before publishing. Effective post window: 17:50 to 18:20 UTC, which lands as 19:50 to 20:20 CEST.
The script reads src/content/blog/*.md, filters by quality score (excludes anything below the published threshold) and by recency (60-day cooldown after a slug was last posted). The eligible pool of ~60 articles never runs out. The selection algorithm is weighted: 30 percent hub articles, 40 percent strategy articles, 30 percent research and fix postmortems.
The 60-day cooldown, which means each article repeats roughly every 3 to 4 months, exists because Nostr timelines have short memory. By that time the hook the script picks should differ from the last one used, which is where the per-article hook cache matters.
Worth noting: the quality-threshold filter is a limitation, not a feature. As of May 2026, roughly 8 articles in the pool sit below threshold and never rotate in. Either those articles get upgraded or they stay off the distribution list permanently.
Per-article hook cache: not templates
The naive approach is six opener templates with {topic} substitution. Render "If you want one place to start with {topic}, this is it." with topic = "self-hosted AI on the DGX Spark", and you have a post. Do this 30 times across rotating templates and a reader watching the brand account can see the loop. Template substitution is detectable because the slot boundaries are visible: the surrounding sentence structure stays constant while only the noun phrase changes.
The refined approach is per-article hook cache, which refers to a JSON file storing 3-5 pre-generated openers per article slug. When the script first selects an article, it sends the article body to the local Mistral instance and asks for 3 to 5 candidate hooks, each 1-2 sentences, each leading with a concrete fact (a number, a named entity, a specific failure). These hooks are cached to config/nostr-hook-cache.json keyed by slug. The distinction from templates is that each hook is generated from the article’s specific content, not from a scaffold with a blank to fill.
Examples from real article hooks generated this way:
- For the Voxtral encoder article:
"Encoder weights stayed gated in Mistral's hosted product. ref_audio crashed the engine. Voice cloning was always closed-source, just labeled otherwise." - For the Qwen3.6 model swap article:
"95.11 tokens per second on a single Spark, Apache 2.0, 22 GB on disk. The math on the model swap." - For the backup post-mortem:
"Six months of automated backups, all of them fake. The script was running. The data wasn't moving."
Per post, the script picks an unused hook for that slug. When all hooks are used, it regenerates a fresh batch. After the 60-day cooldown ends and the article comes back into rotation, the second wave of hooks differs from the first. A reader following the brand account never sees the same opener twice within months.
The Mistral round-trip is cheap because it runs once per new article (not once per post), and is gated behind the quality threshold so the same script that decides whether to post also decides whether to invest the prompt budget. One caveat: the hook quality depends on Mistral being online. If SGLang is down at hook-generation time, the script falls back to a generic headline-only opener rather than block the rotation. That fallback is specifically labeled in the cache file so it gets replaced on the next Mistral-available run.
Multimedia variety: breaking the link-only rhythm
Five link-posts a week, all pointing back to sovgrid.org, is rhythm. Rhythm is exactly what gives an autoposter away to a reader after the third week. The script needs at least one non-link post per week to look human.
The solution is a pre-rendered fun-image pool. A second script, generate_fun_images.py, renders 10 image variations via the same FLUX-schnell ComfyUI pipeline that produces blog hero images. The prompts are character-driven, not article-driven: CIPHERFOX in his basement lab, HEXABELLA walking through an alpine meadow at dawn with source-code fireflies, an exploded isometric of the sovereign-grid stack. Style-consistent with the blog visual vocabulary (neon-green #76b900 accents, alpine and data-center motifs, 35mm cinematic), but the content is mood, not information.
These render in roughly 20 seconds each at 4 steps, so refreshing the pool is cheap. Once a quarter the script reruns with new prompt variations to keep the pool from going stale. Images get uploaded to a Blossom mediaserver (blossom.primal.net) and the URLs live in the daily-pipeline’s image cache.
The integration into the cadence: roughly one in every 5 to 6 posts is image-only. No link, no hashtags beyond #SovereignAI, just an image and a 1-2 sentence caption that picks up the visual. The pool can be hand-pruned (delete the ones that came out boring) without breaking the pipeline. Pure quality control by the operator.
The image-post days do not count against the article-cooldown rotation: they sit alongside the article-link rhythm as deliberate texture-variation. The reader scrolling sovgrid’s feed sees: link, link, image, link, skipped day, link, link. That sequence reads as a person posting from a project, not a bot dispatching a queue.
Tone-guard: same gate as the blog
Before any post leaves the script, a regex pass scans for the anti-patterns from sovereign-kb/mistral-overuse-phrases.md:
- Em-dashes
- Vagueness intensifiers: the word class that includes “fascinat-” prefixed words, absolutist adjectives like “ground-breaking”, rhetorical openers like “great question”
- Sentiment-void adverbs (the ones ending in -ably/-ibly that modify nothing specific)
- Aphorism-couplets (“X is paid in time. Y is paid in money.”)
- Hashtag count > 3
- Total content length > 500 characters
If a hook fails the gate, the script regenerates from cache (pick a different one) or asks Mistral for a fresh batch. Up to 3 retries. If still failing, the script aborts that day’s post and notifies Matrix. Missing a day is fine; publishing slop is not.
The blog’s quality gate gives the brand the same standards as the long-form content. Distribution that contradicts the content’s discipline is worse than no distribution at all.
Cadence-jitter and skip-probability
The cron entry is 50 17 * * 1-5 (17:50 UTC, Monday through Friday). The script sleeps random.randint(0, 1800) seconds before doing anything. This produces post timestamps between 17:50 and 18:20 UTC. Reader-side this looks like “around eight in the evening”, not “exactly 20:00:00 to the second”.
Within the script, an 8 percent random check skips the day entirely (with a log entry and a Matrix notification). On average, four scheduled posts per year get randomly skipped. The brand reads as “a human running this who sometimes forgets” rather than “a relentless 5-day-a-week machine”.
The randomness is the disguise. The cron is still the cron. The reader’s perception is what matters.
Three accounts, three voices, one ecosystem
Most blog brands run one Nostr account. The sovgrid stack runs three. The reason is voice-coherence, not reach-multiplication. Three accounts posting the same type of content at the same frequency would be spam; three accounts with distinct roles, posting different content on different rhythms, is an ecosystem. That distinction is why the three are not interchangeable and not merged.
- cipherfox@sovgrid.org is the operator. Skeptical-engineer voice, war stories, declarative, drops articles (“Pipeline started running”), F-bombs allowed in moderation. Picks the failure stories.
- hexabella@sovgrid.org is the AI co-host on the upcoming podcast. Warm builder, analogy-driven, audience-anchor. Translates the dense engineering log into something a listener can hold without a CUDA driver in their basement.
- blog@sovgrid.org is the brand. Engineering-log voice, deklarative, official-but-not-corporate. Distribution layer for the long-form content.
The three voices stay separate because conflating them collapses the brand into mush. When CIPHERFOX publishes a 0/10 verdict on Voxtral, that lands harder coming from the operator than from the brand. When HEXABELLA introduces herself with “I am not human”, that lands harder from her account than as a paragraph inside a blog post. Voice separation also means each account can grow its own follower set: readers who follow cipherfox for the war stories need not see every brand-level distribution post. That is the separation that makes three accounts worth the overhead rather than a liability.
For launch posts (first post from each account), the other two accounts leave a reply within an hour. This seeds the network graph: Nostr clients see that the three pubkeys interact, and treat them as a related cluster. Reader-side this looks like “three people who know each other talking about the same project”, not “one publisher pretending to be three voices”. The relationship is real, the staging is timing.
Bootstrap: day 1 through day 7
The first week is curated, not algorithmic. The three hub articles get the first three slots so a new reader following the brand account hits the entry-point content first:
| Day | Article |
|---|---|
| Mon | setup-self-hosted-ai-start-here |
| Tue | strategy-roadmap |
| Wed | strategy-hub-articles-protocol |
| Thu | strategy-tts-pivot-voxtral-ceiling (this week’s frontline story) |
| Fri | strategy-next-model-choices-dgx-spark (yesterday’s main piece) |
From week two, the weighted-bucket algorithm picks freely. The curated bootstrap is just to make sure the first week’s followers see the durable content before the rotation kicks in.
Why autoposting beats hand-posting at 60 articles
The honest case for automation at this scale: I have 120 articles and growing. Hand-posting five per week means choosing which five, writing the opener, checking the tone, publishing, and repeating Monday through Friday indefinitely. That is 30 to 40 minutes per week of mechanical work with no creative payoff. The creative work is the article, not the Nostr preview. Automation handles the mechanical layer so that hand-posting remains reserved for events that actually require a human in the loop: a breaking model release, a reply to a named person, a correction.
The limitation here is that automation cannot read the room. If something goes wrong publicly (a factual error in an article, a tool that gets deprecated overnight), the daily cron will keep posting that article until the state file is manually updated. That is not a theoretical risk: it has already happened once, in May 2026, when an article referencing a specific token-per-second benchmark became stale within 48 hours of publish. The cron posted it again 10 days later. The fix was a slug-level suppress flag added to the state file, but the incident confirmed that automation requires active monitoring, not set-and-forget trust.
Why kind:1 over kind:30023 for this cadence
A kind:1 event, which refers to a standard short-form Nostr note, is the right format for this distribution layer rather than kind:30023 (long-form content). The reason is relay coverage: kind:1 is indexed and served by every Nostr relay without exception. Kind:30023, which refers to the NIP-23 long-form article format, requires relay-side support for that event kind and is not stored by the majority of general-purpose relays.
Since the distribution goal is link-plus-hook (post the opener, link to the full article on sovgrid.org), kind:1 is specifically the correct choice. The long-form content lives on the blog, not on Nostr. Kind:30023 would be appropriate if the full article text were being published natively to Nostr rather than referenced, which is a different strategy with different sovereignty tradeoffs.
This is not engagement-bait infrastructure. There is no “tell me which model YOU use” comment-prompt at the end of each post. There is no “thread incoming” multi-part split when a single post would do. The cadence exists to publish links to durable content with self-aware framing, not to harvest replies.
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. A bucket-weight tuning loop that learns what reaches readers and what does not.
That is the next stage. For now, the goal is the boring one: post five times a week, not embarrass the brand, and document the build openly enough that a reader can audit whether the cadence-design matches the cadence-output.
Source link and accounts
The script will live at /data/scripts/blog/sovgrid-nostr-daily.py in the cipherfox/sovereign-ops repo on Gitea once shipped. The mirror to GitHub follows on the next deploy cycle. State file is at /data/scripts/blog/.sovgrid-nostr-state.json. The cron entry lives in /etc/cron.d/sovgrid-nostr with a one-line schedule.
The three Nostr profiles, as of 2026-05-12:
- cipherfox: njump.me/cipherfox@sovgrid.org
- hexabella: njump.me/hexabella@sovgrid.org
- sovgrid: njump.me/blog@sovgrid.org
If the cadence drifts into anything resembling the four bot-fingerprints listed above, that is a build failure and a tell that the implementation is not matching the design. The right response is to fix the script, not to lower the bar.
What I Am Building
- Cron-triggered Python script with random 0-30 min jitter, 8% random skip
- Per-article hook cache, 3-5 hooks per slug, Mistral-generated, regenerated after exhaustion
- Tone-guard regex pass identical to blog quality_gate (em-dash, AI-slop adjectives, aphorism couplets, length cap)
- Weighted-bucket selection (Hub 30 / Strategy 40 / Research+Fixes 30) with 60-day cooldown per slug
- Matrix notification on success, Matrix notification on tone-guard failure, Matrix notification on aborted day
- State file as authoritative source for post-history, last-opener tracking, bootstrap-pointer