# Show HN: Amanuensis – a local-first AI persona that won't fabricate facts

> Source: <https://github.com/msalsas/amanuensis>
> Published: 2026-06-05 16:34:28+00:00

*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](https://hachyderm.io/@alexa_pavlova) · [Bluesky](https://bsky.app/profile/alexapavlova.bsky.social) — 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](https://dev.to/msalsas/building-an-ai-persona-that-doesnt-lie-the-parts-nobody-bothers-to-build-33ii).

```
pip install -e ".[dev]"
cp .env.example .env
```

**LMStudio**

- Download
[LMStudio](https://lmstudio.ai/)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](https://github.com/mcmonkeyprojects/SwarmUI)and load the image model +`41ex4_p4v10v4`

LoRA - Set
`SWARMUI_BASE_URL=http://localhost:7801`

in`.env`

- Message
[@BotFather](https://t.me/botfather)→`/newbot`

→ copy the token into`TELEGRAM_BOT_TOKEN`

- Message
[@userinfobot](https://t.me/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**:

```
# 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.

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](https://huggingface.co/msalsas/alexa-lora). Trigger word`41ex4_p4v10v4`

, weight`0.3`

, generated at 768×1024.**Base model:**[Juggernaut XL "Ragnarok" by RunDiffusion](https://civitai.com/models/133005?modelVersionId=1759168)— 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+
[LMStudio](https://lmstudio.ai/)running locally (OpenAI-compatible API)[SwarmUI](https://github.com/mcmonkeyprojects/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](https://openweathermap.org/api)API key (free tier, for ambient context in prompts)

```
pip install -e ".[dev]"
cp .env.example .env
# Fill in .env with your tokens and service URLs
```

| 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/
# 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"`

).

```
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
pytest tests/ -v      # 312 tests, all mocked — no live services required
```

All HTTP calls (LMStudio, SwarmUI, Telegram, HN, Algolia, etc.) are mocked with `respx`

.
