Show HN: Amanuensis – a local-first AI persona that won't fabricate facts
Amanuensis is a local-first AI persona system for posting on Mastodon and Bluesky. It prevents model hallucination through strict pipelines: factual source summaries, deterministic cleanup, regex pre-checks, LLM grounding checks, and human approval via Telegram. MIT-licensed experimental code.
Notifications You must be signed in to change notification settings
Fork 0
Star 0
BranchesTags
Open more actions menu
Folders and files
NameName
Last commit message
Last commit date
Latest commit
History
3 Commits
3 Commits
adapters
adapters
config
config
core
core
docs/img
docs/img
profiles/alexa
profiles/alexa
scripts
scripts
services
services
social
social
tests
tests
.env.example
.env.example
.gitignore
.gitignore
LICENSE
LICENSE
README.md
README.md
main_batch.py
main_batch.py
main_dispatcher.py
main_dispatcher.py
main_reply_listener.py
main_reply_listener.py
main_telegram_listener.py
main_telegram_listener.py
manual_post.py
manual_post.py
pyproject.toml
pyproject.toml
Repository files navigation
A local-first AI persona that writes under a human's veto. It drafts, you approve, and nothing it can't ground gets published.
A local-first pipeline for running an AI persona on Mastodon and Bluesky. The pilot persona, AlexaPavlova, posts as a sarcastic senior Berlin dev with dry takes on tech news and open source.
Everything runs on a local GPU machine. No cloud LLM calls.
See it live: Mastodon · Bluesky — now disclosed as AI, no longer posting.
Every post is reviewed on a phone before it publishes — approve, regenerate text or image, or cancel.
Status: this was an experiment, not an active product. The code is MIT-licensed and works end-to-end — fork it, learn from it, run your own persona. Issues and PRs may not get a response.
What's interesting here
The hard part wasn't generating text, it was stopping the model from fabricating technical detail. The short version: factual-only source summaries, deterministic cleanup before any LLM judgment, a regex pre-screen in front of an LLM grounding check, titles-only memory, and a human approving every post over Telegram.
Full write-up of the design and what broke along the way: write-up.
Quick start
- Install dependencies
pip install -e ".[dev]" cp .env.example .env
- Start local services
LMStudio
Download LMStudio and load any instruction-tuned model (tested with Mistral-7B-Instruct and similar)
Go to Local Server → start the server on port 1234
Set LMSTUDIO_BASE_URL=http://localhost:1234 in .env
SwarmUI
Install SwarmUI and load the image model + 41ex4_p4v10v4 LoRA
Set SWARMUI_BASE_URL=http://localhost:7801 in .env
- Set up Telegram
Message @BotFather → /newbot → copy the token into TELEGRAM_BOT_TOKEN
Message @userinfobot → copy your numeric ID into TELEGRAM_CHAT_ID
Send any message to your new bot so it can message you back
- Verify with a dry run
python main_batch.py --dry-run
This fetches real stories and generates posts + images using your local services. Nothing is written to any database and nothing is sent to Telegram. If this prints 8 posts, your local stack is working.
- Run for real
Add your social credentials to .env (Mastodon and/or Bluesky — both optional, see table below), then open three terminals:
Terminal 1 — generate today's posts and send to Telegram for approval
python main_batch.py
Terminal 2 — listen for your Telegram approvals
python main_telegram_listener.py
Terminal 3 — publish approved posts at their scheduled time
python main_dispatcher.py
Approve posts in Telegram. The dispatcher picks them up and publishes. Done.
For long-running setups, run the three persistent processes under systemd or supervisord so they survive reboots. main_batch.py is a one-shot script — run it via cron or manually each day.
Image model
The persona's images come from a custom LoRA trained on top of Juggernaut XL "Ragnarok" (SDXL), generated through SwarmUI. The LoRA is not included in this repo — it only contains trained deltas, not the base model.
LoRA: download from Hugging Face — msalsas/alexa-lora. Trigger word 41ex4_p4v10v4, weight 0.3, generated at 768×1024.
Base model: Juggernaut XL "Ragnarok" by RunDiffusion — get it separately, not distributed here.
The LoRA was trained on a fully synthetic dataset (images generated with Juggernaut XL); the character is not based on any real person.
To run the alexa profile with images you need both: load Juggernaut XL in SwarmUI and apply this LoRA. See the model card on Hugging Face for the exact prompt format.
Architecture
Adapters (HN, Lobsters, BearBlog, AskHN) └── Curator (dedup by URL + title + subreddit, banned-topic filter) └── BatchFactory ├── Brain (LMStudio → post text + image prompt) └── ImageService (SwarmUI → PNG via LoRA) └── Scheduler (UTC time windows with jitter) └── QueueService (SQLite) └── TelegramNotifier (photo + approval keyboard) ├── MastodonPublisher (on APPROVE) └── BlueskyPublisher (on APPROVE)
Reply pipeline (runs in parallel): ReplyListener (random poll, 30 min – 2 h per post) ├── MastodonCommentFetcher / BlueskyCommentFetcher ├── Brain.evaluate_relevance() → skip or draft reply ├── Brain.generate_reply() └── TelegramNotifier (REPLY_APPROVE / REPLY_CANCEL) ├── MastodonPublisher.publish_reply() (on APPROVE) └── BlueskyPublisher.publish_reply() (on APPROVE)
Requirements
Python 3.10+
LMStudio running locally (OpenAI-compatible API)
SwarmUI running locally with the 41ex4_p4v10v4 LoRA loaded
A Telegram bot token + chat ID (for the approval workflow)
Mastodon and/or Bluesky credentials (for publishing)
An OpenWeatherMap API key (free tier, for ambient context in prompts)
Setup
pip install -e ".[dev]" cp .env.example .env
Fill in .env with your tokens and service URLs
.env reference
Variable Description
LMSTUDIO_BASE_URL LMStudio API base, e.g. http://localhost:1234
SWARMUI_BASE_URL SwarmUI base, e.g. http://localhost:7801
TELEGRAM_BOT_TOKEN Bot token from @BotFather
TELEGRAM_CHAT_ID Your personal chat ID (use @userinfobot to find it)
ACTIVE_PROFILE Profile slug, default alexa
WEATHER_API_KEY OpenWeatherMap key (free)
ALEXA_MASTODON_ACCESS_TOKEN Mastodon token for the alexa profile
ALEXA_BLUESKY_APP_PASSWORD Bluesky app password for the alexa profile
MASTODON_ACCESS_TOKEN Global fallback Mastodon token (used if no prefixed var found)
MASTODON_INSTANCE_URL Fallback — prefer setting mastodon_instance_url in identity.yaml
BLUESKY_HANDLE Fallback — prefer setting bluesky_handle in identity.yaml
BLUESKY_APP_PASSWORD Global fallback Bluesky app password
Daily workflow
main_batch.py generates 8 posts + images and sends each to Telegram for approval (run via cron).
You APPROVE / REGEN / CANCEL on your phone.
main_dispatcher.py publishes approved posts at their scheduled time (persistent loop).
main_reply_listener.py polls published posts, drafts replies to incoming comments, and sends them back through Telegram for approval (persistent loop).
The three persistent loops (dispatcher, telegram_listener, reply_listener) belong under systemd or supervisord.
Preview without writing anything
python main_batch.py --dry-run
Fetches real stories, generates text and images via local services, prints everything to stdout. No DB writes, no Telegram, no side effects.
python main_dispatcher.py --dry-run
Logs what would be published for each approved post without making any social API calls.
Entry points
Script Purpose
main_batch.py Run once daily — fetches stories, generates posts, saves to memory + queue, notifies Telegram
main_dispatcher.py Persistent loop — publishes APPROVED posts at their scheduled UTC time
main_telegram_listener.py Persistent loop — handles APPROVE / REGEN / CANCEL / REPLY_APPROVE / REPLY_CANCEL callbacks
main_reply_listener.py Persistent loop — polls published posts for new comments, drafts replies, sends for Telegram approval
Slot distribution
Each daily batch generates 8 posts by default (profiles/alexa/identity.yaml):
Category Count Sources
TECH 5 Hacker News, Lobste.rs, BearBlog
PERSONAL 2 Ask HN discussion threads
RAW 1 Internally generated (no source story)
Adding a new profile
mkdir -p profiles/marco/prompts profiles/marco/generated cp profiles/alexa/identity.yaml profiles/marco/ cp profiles/alexa/prompts/*.j2 profiles/marco/prompts/
Edit profiles/marco/identity.yaml and the .j2 templates
Run with the new profile
ACTIVE_PROFILE=marco python main_batch.py --dry-run
The directory name (slug) must match ACTIVE_PROFILE. It is separate from the name field in identity.yaml ("alexa" vs "AlexaPavlova").
Project structure
config/ schemas.py # RawStory, Post, ProfileConfig (Pydantic v2) settings.py # Pydantic-settings from .env core/ brain.py # LMStudio calls, text cleaning, truncation curator.py # Dedup by URL + title + subreddit; banned-topic filter factory.py # Orchestrates adapters → curator → brain → image scheduler.py # UTC-aware time windows with random jitter profile_loader.py # Loads profiles/{slug}/identity.yaml adapters/ hn_adapter.py # Hacker News top stories (TECH) lobsters_adapter.py # Lobste.rs hottest (TECH) bearblog_adapter.py # BearBlog Discover RSS (TECH) ask_hn_adapter.py # Ask HN discussion posts via Algolia (PERSONAL) reddit_adapter.py # Reddit (requires OAuth2 credentials; not used by default) services/ memory_service.py # Per-profile SQLite post history + platform IDs queue_service.py # Approval queue (SQLite) image_gen.py # SwarmUI REST client notification.py # Telegram sendPhoto/sendMessage, approval keyboards, long-poll comment_service.py # comments.sqlite: comments, pending_replies, poll_state social/ mastodon_publisher.py # Mastodon REST — publish + publish_reply bluesky_publisher.py # AT Protocol XRPC — publish + publish_reply mastodon_comment_fetcher.py # Fetches replies via /api/v1/statuses/{id}/context bluesky_comment_fetcher.py # Fetches replies via app.bsky.feed.getPostThread profiles/ alexa/ identity.yaml # Persona config: slots, sources, banned topics, image model prompts/*.j2 # Jinja2 templates: system prompt + per-mood + per-platform generated/ # Output images (slot_NNN.png) memory.sqlite # Post history injected as context into each prompt queue.sqlite # Approval queue comments.sqlite # Comments, pending replies, poll state
Tests
pytest tests/ -v # 312 tests, all mocked — no live services required
All HTTP calls (LMStudio, SwarmUI, Telegram, HN, Algolia, etc.) are mocked with respx.
About
A local-first AI persona for Mastodon and Bluesky — drafts by machine, approved by a human, nothing it can't ground gets published.
dev.to/msalsas/building-an-ai-persona-that-doesnt-lie-the-parts-nobody-bothers-to-build-33ii
Topics
python
ai
mastodon
human-in-the-loop
bluesky
stable-diffusion
llm-local-first
Resources
Readme
License
MIT license
Uh oh!
There was an error while loading. Please reload this page.
Activity
Stars
0 stars
Watchers
0 watching
Forks
0 forks
Report repository
Contributors
Uh oh!
There was an error while loading. Please reload this page.
Languages
Python 97.2%
Jinja 2.8%