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.
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.
pip install -e ".[dev]"
cp .env.example .env
LMStudio
-
Download LMStudioand 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
SwarmUIand load the image model +
41ex4_p4v10v4
LoRA - Set
SWARMUI_BASE_URL=http://localhost:7801
in.env
- Message
@BotFather→
/newbot
→ copy the token intoTELEGRAM_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
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.
Add your social credentials to .env
(Mastodon and/or Bluesky — both optional, see table below), then open three terminals:
python main_batch.py
python main_telegram_listener.py
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
orsupervisord
so they survive reboots.main_batch.py
is a one-shot script — run it via cron or manually each day.
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 word41ex4_p4v10v4
, weight0.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.
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)
- Python 3.10+
LMStudiorunning locally (OpenAI-compatible API)SwarmUIrunning 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 OpenWeatherMapAPI key (free tier, for ambient context in prompts)
pip install -e ".[dev]"
cp .env.example .env
| 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 |
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.
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.
| 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 |
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) |
mkdir -p profiles/marco/prompts profiles/marco/generated
cp profiles/alexa/identity.yaml profiles/marco/
cp profiles/alexa/prompts/*.j2 profiles/marco/prompts/
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"
).
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_.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
pytest tests/ -v # 312 tests, all mocked — no live services required
All HTTP calls (LMStudio, SwarmUI, Telegram, HN, Algolia, etc.) are mocked with respx
.