diff --git a/.~lock.Vasyl-Personal-Projects.pdf# b/.~lock.Vasyl-Personal-Projects.pdf# new file mode 100644 index 00000000..7163a43e --- /dev/null +++ b/.~lock.Vasyl-Personal-Projects.pdf# @@ -0,0 +1 @@ +,blissful-funny-gauss,claude,14.05.2026 16:12,file:///sessions/blissful-funny-gauss/.config/libreoffice/4; \ No newline at end of file diff --git a/.~lock.Vasyl-Resume.pdf# b/.~lock.Vasyl-Resume.pdf# new file mode 100644 index 00000000..7163a43e --- /dev/null +++ b/.~lock.Vasyl-Resume.pdf# @@ -0,0 +1 @@ +,blissful-funny-gauss,claude,14.05.2026 16:12,file:///sessions/blissful-funny-gauss/.config/libreoffice/4; \ No newline at end of file diff --git a/.~lock.textstack-gemma4-submission-package.pdf# b/.~lock.textstack-gemma4-submission-package.pdf# new file mode 100644 index 00000000..62f5cd38 --- /dev/null +++ b/.~lock.textstack-gemma4-submission-package.pdf# @@ -0,0 +1 @@ +,blissful-funny-gauss,claude,08.05.2026 15:15,file:///sessions/blissful-funny-gauss/.config/libreoffice/4; \ No newline at end of file diff --git a/AI-ENGINEER-ROADMAP.md b/AI-ENGINEER-ROADMAP.md new file mode 100644 index 00000000..cee97bca --- /dev/null +++ b/AI-ENGINEER-ROADMAP.md @@ -0,0 +1,388 @@ +# AI Engineer — Career Roadmap + +**Owner:** Vasyl Vdovychenko +**Created:** 2026-05-13 +**Goal:** Position as AI Engineer and start interviewing in ~6 weeks + +--- + +## Part 1 — Current Profile Audit + +### Claims I removed from LinkedIn About (and why) + +| Removed | Reason | +|---------|--------| +| **"Hybrid search"** (RAG bullet) | Hybrid = vector + BM25 / keyword. My `rag-chatbot-dotnet` uses vector retrieval only. | +| **"Multi-agent workflows"** (Agent bullet) | No 2+ agents collaborating in my repos. Have single agents with tools, not multi-agent. | +| **"Response distillation"** (Production AI infra) | Distillation = training a smaller model on outputs of a larger one. I do model **selection** (E4B → E2B), which is different. | +| **"Kubernetes"** (Full-stack) | Use Docker Compose + Cloudflare Tunnel, not K8s. No K8s manifests in any repo. | +| **"Anthropic Claude"** (LLM integration) | `.env.example` has `CLAUDE_API_KEY` placeholder, but I haven't verified actual Claude SDK calls in code. **Don't claim until verified.** | + +### Final defensible LinkedIn About (every word backed by code) + +``` +AI Engineer with 10+ years in software engineering. Building production AI systems — +RAG pipelines, agent architectures, LLM orchestration, and observability for ML workloads. + +Currently a Senior Software Engineer at Pinnacle focused on SDK and testing tooling; +pursuing AI engineering through personal open-source work. Looking to make this my +full-time focus. + +What I build: + +• RAG pipelines — Pinecone vector retrieval, OpenAI embeddings, document chunking, + context-grounded generation via Semantic Kernel + +• Agent architectures — ReAct loops, tool orchestration, MCP server implementation, + function calling, single-agent RAG workflows + +• LLM integration — OpenAI, Ollama, Semantic Kernel; model selection, + quantization tradeoffs, prompt design + +• Production AI infrastructure — cost optimization ($0.002/load test cycle), + multi-tier caching, fall-back routing, model selection + +• Observability for ML systems — distributed tracing (OpenTelemetry + Aspire), + load testing at burst (63K req, p95 20.5ms validated) + +• Full-stack delivery — ASP.NET Core, React, React Native (Expo), Postgres, + Docker Compose, CI/CD + +Open to: AI Engineer · ML Engineer · LLM Engineer · AI Infrastructure Engineer · +AI Platform Engineer · Applied AI Engineer. + +Background: 10+ years building scalable cloud systems on .NET / Azure stack. +``` + +### Headline (final) + +``` +AI Engineer | RAG · Agents · LLM Infrastructure | 10+ years in software engineering +``` + +--- + +## Part 2 — AI Engineer Skill Matrix + +What an "ideal AI Engineer" portfolio typically has, and where I stand. + +Legend: ✅ have it · ⚠️ partial · ❌ missing + +### LLM Integration + +| Skill | Status | Evidence | +|-------|--------|----------| +| OpenAI API | ✅ | rag-chatbot, textstack, AiAgents | +| Anthropic Claude API | ⚠️ | .env exists, real SDK call to verify | +| Google Gemini | ⚠️ | .env exists, real SDK call to verify | +| Ollama (open-source models) | ✅ | textstack (Gemma 4) | +| Multi-provider abstraction | ✅ | AI_PROVIDER switch in AiAgents | + +### RAG (Retrieval-Augmented Generation) + +| Skill | Status | Evidence | +|-------|--------|----------| +| Vector databases (Pinecone/Qdrant/Chroma) | ✅ | Pinecone in rag-chatbot | +| OpenAI embeddings | ✅ | rag-chatbot | +| Document chunking | ✅ | rag-chatbot (Wikipedia indexer) | +| Hybrid search (vector + BM25) | ❌ | — | +| Re-ranking (Cohere, etc.) | ❌ | — | +| Long-context strategies | ❌ | — | +| RAG evaluation (RAGAS) | ❌ | — | + +### Agents + +| Skill | Status | Evidence | +|-------|--------|----------| +| Function calling | ✅ | FunctionRegistry.cs in AiAgents | +| Tool orchestration | ✅ | ConsoleAgent (weather tool) | +| ReAct loop | ✅ | AiAgents docs + code | +| Agent memory | ✅ | Memory systems in AiAgents docs | +| **MCP server** | ✅ | **Real McpServer (tools + prompts + resources)** | +| MCP client | ❌ | Built server only, not client | +| Multi-agent collaboration | ❌ | — | +| Agent evaluation | ❌ | — | + +### Local LLM / On-device + +| Skill | Status | Evidence | +|-------|--------|----------| +| Ollama | ✅ | textstack | +| WebLLM (browser inference) | ✅ | ReplyMate | +| Quantization knowledge (Q4, GGUF) | ✅ | VRAM math post | +| GPU offload tuning | ✅ | VRAM math post (2.5× measured) | +| llama.cpp directly | ❌ | — | +| vLLM / TGI / Triton | ❌ | — | + +### Production AI Infrastructure + +| Skill | Status | Evidence | +|-------|--------|----------| +| Caching strategies | ✅ | textstack disk cache + IndexedDB | +| Cost optimization | ✅ | $0.002/load test (measured) | +| Fall-back routing | ✅ | MC fallback cascade | +| Rate limiting | ✅ | textstack rate-limit middleware | +| Async inference / queues | ✅ | textstack worker | +| A/B testing models | ❌ | — | +| Shadow deployment | ❌ | — | + +### Observability / MLOps + +| Skill | Status | Evidence | +|-------|--------|----------| +| Distributed tracing OTel | ✅ | xUnitOTel + textstack | +| Load testing | ✅ | LoadSurge (63K req validated) | +| Latency budgets | ✅ | Load test report | +| Cost / token tracking | ⚠️ | Partial, not explicit dashboard | +| LLM eval suites | ❌ | — | +| LangSmith / Helicone | ❌ | — | +| Prompt versioning | ❌ | — | + +### ML Fundamentals + +| Skill | Status | Evidence | +|-------|--------|----------| +| Embeddings | ✅ | rag-chatbot | +| Transformer arch (engineer-level) | ⚠️ | NVIDIA course in progress | +| Quantization formats (GGUF/AWQ) | ⚠️ | Knows via Ollama, not deep | +| Tokenizers (BPE) | ⚠️ | Concept, not hands-on | +| Fine-tuning / LoRA / PEFT | ❌ | — | +| RLHF / DPO | ❌ | — | + +### Frameworks + +| Skill | Status | Evidence | +|-------|--------|----------| +| Semantic Kernel | ✅ | rag-chatbot | +| Microsoft.Extensions.AI | ✅ | rag-chatbot | +| LangChain | ❌ | — | +| LlamaIndex | ❌ | — | +| DSPy | ❌ | — | +| AutoGen / CrewAI | ❌ | — | +| Haystack | ❌ | — | + +### Deployment + +| Skill | Status | Evidence | +|-------|--------|----------| +| Docker / Docker Compose | ✅ | every repo | +| Azure (cloud) | ✅ | Pinnacle background | +| AWS / GCP | ⚠️ | Familiar, not visible in repos | +| Kubernetes | ❌ | use compose | +| GPU cluster mgmt | ❌ | — | +| Model serving (vLLM, Triton) | ❌ | — | + +### Safety / Eval + +| Skill | Status | Evidence | +|-------|--------|----------| +| Prompt injection defense | ⚠️ | textstack SeoPromptSanitizer (basic) | +| Guardrails | ❌ | — | +| Red-teaming | ❌ | — | +| RAGAS / eval frameworks | ❌ | — | + +### Languages + +| Lang | Status | Notes | +|------|--------|-------| +| C# / .NET | ✅✅✅ | 12 years | +| TypeScript | ✅ | textstack frontend, ReplyMate | +| **Python** | ❌ | **Biggest gap for AI roles** | +| SQL | ✅ | Postgres | + +--- + +## Part 3 — 6-Week Learning Plan + +**Goal:** Close the top 3 most-demanded gaps — Python, LangChain/LlamaIndex, RAG evaluation — before flipping LinkedIn Open-to-Work toggle. + +### Cadence assumption + +Day job: ~40h/week. Available learning time: ~8-10h/week (evenings + weekends). + +Each week has one **shippable artifact** (a repo, doc, or LinkedIn-ready milestone). + +--- + +### Week 1 (May 13 – May 19) — Python AI Foundations + +**Goal:** Stop being a "C# guy who knows AI" — establish Python AI workflow. + +**Tasks:** +- [ ] Set up Python 3.12 + uv + ruff + ipython on dev machine +- [ ] Install OpenAI Python SDK, run hello-world chat completion +- [ ] Install Anthropic Python SDK, run hello-world (covers the Claude gap honestly) +- [ ] Re-implement `rca` (your LLM-powered test-log analyzer) in Python — single file, ~200 LoC +- [ ] Add it to GitHub as `rca-py` with a clear README + +**Deliverable:** `github.com/mrviduus/rca-py` — Python port with both OpenAI and Claude support. + +**Why:** This single repo lets me legitimately add "Python · Anthropic Claude" to my LinkedIn skills. + +**Resources:** +- Anthropic SDK quickstart: https://docs.anthropic.com/en/api/getting-started +- OpenAI Python SDK: https://github.com/openai/openai-python + +--- + +### Week 2 (May 20 – May 26) — LangChain RAG + +**Goal:** Build the same thing my `rag-chatbot-dotnet` does, but in LangChain. This is the most-searched framework on LinkedIn. + +**Tasks:** +- [ ] Read LangChain "Get started" + "RAG tutorial" +- [ ] Build a RAG chatbot in Python + LangChain that mirrors my rag-chatbot-dotnet (Wikipedia → Pinecone → OpenAI) +- [ ] Add semantic chunking (`langchain.text_splitter.RecursiveCharacterTextSplitter` with overlap) +- [ ] Add streaming responses +- [ ] Document the architecture in README with a diagram + +**Deliverable:** `github.com/mrviduus/rag-chatbot-langchain` — Python equivalent with proper chunking strategy documented. + +**Why:** Recruiter searches for "LangChain" return 10× more results than "Semantic Kernel". This single repo makes me searchable. + +**Resources:** +- LangChain RAG tutorial: https://python.langchain.com/docs/tutorials/rag/ +- Pinecone + LangChain: https://docs.pinecone.io/integrations/langchain + +--- + +### Week 3 (May 27 – Jun 2) — Hybrid Search + Re-ranking + +**Goal:** Close the "hybrid search" gap honestly — actually build it. + +**Tasks:** +- [ ] Add BM25 retriever to the LangChain rag-chatbot (from `langchain_community.retrievers`) +- [ ] Combine BM25 + vector via `EnsembleRetriever` (50/50 weight) +- [ ] Add Cohere or local re-ranker on top-N results +- [ ] A/B test: pure vector vs hybrid+rerank on 20 hand-written queries — log win rate +- [ ] Write up findings as a dev.to post: "Hybrid search measured: I tested vector vs BM25 vs ensemble" + +**Deliverable:** +- Updated rag-chatbot-langchain with hybrid + rerank +- A dev.to post with measurement table + +**Why:** Now "hybrid search" is REAL on my profile. Plus another technical post for portfolio. + +**Resources:** +- LangChain EnsembleRetriever: https://python.langchain.com/docs/how_to/ensemble_retriever/ +- Cohere rerank: https://docs.cohere.com/docs/reranking + +--- + +### Week 4 (Jun 3 – Jun 9) — RAG Evaluation with RAGAS + +**Goal:** Add LLM-eval skill — a senior-marker most engineers don't have. + +**Tasks:** +- [ ] Install RAGAS (`pip install ragas`) +- [ ] Build a 30-question eval dataset for the rag-chatbot (manual, ~2 hours) +- [ ] Run RAGAS metrics: faithfulness, answer relevancy, context precision, context recall +- [ ] Generate a metrics report (markdown table) +- [ ] Optionally: add a CI workflow (`.github/workflows/ragas-eval.yml`) that runs on every PR + +**Deliverable:** +- RAGAS eval suite in rag-chatbot-langchain repo +- README section showing the metrics +- (stretch) GitHub Actions running RAGAS on every commit + +**Why:** "LLM evaluation" with concrete metrics is what separates senior from mid AI engineer candidates. + +**Resources:** +- RAGAS: https://docs.ragas.io/en/stable/ +- LangSmith for tracing (optional): https://docs.smith.langchain.com/ + +--- + +### Week 5 (Jun 10 – Jun 16) — Multi-Agent Collaboration + +**Goal:** Close the "multi-agent" gap honestly — build it. + +**Tasks:** +- [ ] Pick CrewAI or AutoGen (CrewAI is friendlier; AutoGen is Microsoft so synergies with my .NET background) +- [ ] Build a 3-agent system: **Planner → Researcher → Critic** + - Planner breaks down a question into sub-tasks + - Researcher uses RAG (your existing LangChain pipeline) to gather facts + - Critic reviews the synthesis and asks for clarifications +- [ ] Pick a non-trivial task: "Plan a 3-day technical conference agenda from a PDF of past schedules" +- [ ] Document the agent topology with a diagram + +**Deliverable:** `github.com/mrviduus/multi-agent-research` — working 3-agent pipeline + +**Why:** Now "multi-agent workflows" is REAL. Also opens conversation in interviews about agent design tradeoffs. + +**Resources:** +- CrewAI: https://docs.crewai.com/ +- AutoGen: https://microsoft.github.io/autogen/ + +--- + +### Week 6 (Jun 17 – Jun 23) — Polish + LinkedIn Flip + +**Goal:** Update profile to reflect new skills and start interviewing. + +**Tasks:** +- [ ] Update LinkedIn About to add new bullets: + - Python in LLM Integration + - Hybrid search + RAGAS in RAG Pipelines + - Multi-agent in Agent Architectures +- [ ] Add Featured items: rca-py, rag-chatbot-langchain, multi-agent-research +- [ ] Add Skills: Python, LangChain, LlamaIndex (if used), RAGAS, CrewAI / AutoGen +- [ ] Reorder Top Skills: LLM, RAG, AI Engineering, **Python**, Agents +- [ ] **Flip "Open to Work" toggle ON** (Recruiters only visibility) +- [ ] Add 5 AI Engineer job titles in Open To Work +- [ ] Write a LinkedIn post: "What I built in 6 weeks pivoting to AI Engineering" — links to all 4 new repos +- [ ] Connect with 10-15 AI engineers in Toronto / Waterloo / remote-Canada + +**Deliverable:** Profile that reads as senior AI Engineer with proof, recruiter messages start arriving within 1-2 weeks. + +--- + +## Part 4 — Top 3 Gaps to Close (TL;DR) + +In priority order: + +1. **Python** — Week 1. Without this, "AI Engineer" doesn't read as believable for most roles. +2. **LangChain or LlamaIndex** — Week 2. Recruiter searches for these in 10× more volume than Semantic Kernel. +3. **LLM evaluation (RAGAS)** — Week 4. Senior-marker that most candidates miss. + +Everything else (multi-agent, hybrid search, rerank) is layered on top across weeks 3 and 5. + +--- + +## Part 5 — Nice-to-Have (After Week 6) + +If interview pipeline is slow or I want extra ammo: + +| Skill | Why | Time | +|-------|-----|------| +| Real Anthropic Claude integration code | Closes the .env-only gap | 1 evening | +| Vector DB diversity (add Qdrant or Chroma) | Shows you can pick, not just use one | 1 weekend | +| Fine-tuning experience (LoRA) | One HuggingFace training run = resume signal | 1 weekend | +| Kubernetes basics (deploy textstack to k3s) | Required for some enterprise roles | 1 week | +| LangSmith / Helicone observability | LLM-specific tracing tools | 1 evening | + +--- + +## Part 6 — Success Metrics + +How I know this worked: + +- [ ] All 4 new repos live on GitHub with README + topics +- [ ] LinkedIn About updated with backed claims (every word defensible) +- [ ] Open To Work flipped on (recruiters only) +- [ ] LinkedIn search for "AI Engineer Canada" surfaces my profile in top 50 +- [ ] First recruiter inreach within 2 weeks of Open To Work flip +- [ ] First technical screen by week 8 (2 weeks after flip) + +--- + +## Decision Log + +| Date | Decision | Reason | +|------|----------|--------| +| 2026-05-13 | Skip $125 NVIDIA cert payment | Senior engineers don't need "Fundamentals" cert; portfolio speaks louder | +| 2026-05-13 | Headline: "AI Engineer \| RAG · Agents · LLM Infrastructure \| 10+ years in software engineering" | Capability-focused, no project URL, no "Local LLM" niche | +| 2026-05-13 | About: capability-led, not project-led | Senior engineer About reads as skills, not portfolio site | +| 2026-05-13 | Don't flip Open to Work yet | 1 month buffer to add Python + LangChain + RAGAS + multi-agent before recruiter spam | +| 2026-05-13 | Add textstack as separate Experience entry (Self-employed, AI Engineer) | Recruiter search filters by Experience title — needs that entry to be findable | +| 2026-05-13 | "Share profile updates" already OFF | Profile changes don't broadcast to colleagues | diff --git a/PLAN-ai-portfolio.md b/PLAN-ai-portfolio.md new file mode 100644 index 00000000..e05b65fd --- /dev/null +++ b/PLAN-ai-portfolio.md @@ -0,0 +1,226 @@ +# TextStack — AI Portfolio Roadmap + +**Fixed**: 2026-05-15 · **Target**: pre-Oct 2026 launch · **Mode**: AI-engineering portfolio + product differentiator + +## Why this plan + +Project goal is twofold: (a) ship paying-customer product by Oct 2026, (b) build a serious AI-engineering portfolio. Existing AI surfaces (Explain, Translate, Distractor/Hint/Explanation gen via Ollama, SEO generation via Claude CLI, prompt injection sanitizer, immutable replay for SEO jobs) are already production-grade and underused as portfolio material. This plan sequences the next moves without sacrificing the Oct 2026 deadline. + +**Hard rule**: nothing here ships before mobile feature-parity on Google Play. Without users, AI features are demos. + +--- + +## Sequence + +| # | Step | Duration | Why now | +|---|------|----------|---------| +| 1 | **Mobile feature-parity + Google Play launch** | 3–4 weeks | Without mobile, no paying customers, no real users for AI features | +| 2 | **Observability + eval on existing AI** | 1 week | Free portfolio uplift; required before adding new AI | +| 3 | **Podcast generation (MVP)** | 1 week | Killer differentiator, viral-friendly, simple stack, uses existing Edge TTS | +| 4 | **RAG "Ask this book"** | 2–3 weeks | Deep AI feature with pgvector + hybrid retrieval | +| 5 | **Podcast voice upgrade (optional)** | 1 day | Swap Edge TTS → ElevenLabs or OpenAI `tts-1-hd` for quality | + +--- + +## Step 1 · Mobile Google Play launch *(unchanged — already in flight)* + +Out of scope for this doc. See existing mobile track in `PLAN-presale-8w.md`. + +--- + +## Step 2 · Observability + eval on existing AI + +Goal: turn the "I shipped 5 AI features" into "I shipped 5 AI features with eval + observability". This is what separates mid+ from junior on interviews. + +**What to build:** + +- Log every LLM call (Explain, Translate, Distractor, Hint, Explanation, BookMetadata, SEO) with: input, output, model, latency, token cost, IP/user, cache hit/miss. +- Admin page `AI Quality`: + - Recent calls table, filter by surface (explain / translate / …) + - Manual rating buttons: good / bad / needs-fix + - Cost dashboard (per surface, per day) +- Eval dataset (~100 examples per surface): + - Inputs + expected concepts the output should include + - Run on every prompt change (CLI: `dotnet run --project tools/AiEval`) + - Outputs: pass rate, regression diff vs previous run +- LLM-as-judge for soft criteria (faithfulness, helpfulness) — use Claude as judge over gpt-5-mini outputs + +**Acceptance**: prompt change blocked from merging unless eval suite passes. Talking point on interview: "every AI surface has a regression-tested prompt". + +--- + +## Step 3 · Podcast generation (MVP) + +Goal: "Listen to DDIA Chapter 5 as a 20-min podcast". Killer differentiator, no one else does this for the technical-books niche. Plays into "Finish English technical books" positioning. + +### Architecture + +``` +backend/src/Domain/Entities/ + PodcastGenerationJob.cs // queued / processing / ready / failed + Podcast.cs // EditionId, Mp3Path, Duration, ScriptJson, CreatedAt + +backend/src/Worker/Services/ + PodcastWorkerService.cs // polls queue, runs pipeline + +backend/src/Application/Podcast/ + ScriptGenerator.cs // LLM → JSON dialogue + PodcastSynthesizer.cs // multi-voice TTS + FFmpeg stitch + +backend/src/Api/Endpoints/ + PodcastEndpoints.cs // GET /api/books/{slug}/podcast(.mp3|/script) +``` + +### Pipeline + +1. **Chunk content** — collect `Chapter.PlainText` per edition. For large books: summarize each chapter to 500–800 words via gpt-5-mini → "book brief" ~5–10K words. +2. **Generate script** — prompt gpt-5-mini / Claude to produce a 20-min dialogue: + - Host (curious, asks questions) + Expert (knowledgeable, explains) + - Output strict JSON: `[{speaker: "host"|"expert", text: string, pause_after_ms: number}]` + - Aim for ~3000–5000 words of dialogue +3. **Synthesize each line** — call existing `EdgeTtsService` per line: + - Host = `en-US-AriaNeural`, Expert = `en-US-GuyNeural` + - Parallelize per line, cache per SHA256(line + voice) +4. **Stitch with FFmpeg** — concat reps with 300–500ms pauses, normalize with `loudnorm`, output mp3 ~30–50MB. +5. **Store & serve** — + - `data/storage/books/{editionId}/podcast.mp3` + - `data/storage/books/{editionId}/podcast-script.json` + - Endpoint streams mp3 with `Range` header support +6. **Reader integration** — `🎧 Listen` button on book detail and reader; player + transcript with click-to-jump. +7. **Mobile** — RN audio player with lock-screen controls (`expo-av` or `react-native-track-player`). + +### Cost per podcast (30 min) + +- LLM script gen: ~$0.05 (gpt-5-mini) +- TTS: $0 (Edge TTS) +- Storage: 30–50MB +- For full 1500-book corpus pre-generation: ~$75 + ~75GB disk + +### Acceptance + +- Generate podcast for one technical book (e.g. DDIA) end-to-end +- Plays smoothly on web + mobile +- Lock-screen controls work on Android +- Admin can re-trigger generation + +### Marketing payoff + +Short demo video for Twitter/Dev.to: "Listen to DDIA Chapter 5 as a podcast — generated on the fly, free, with TextStack". This is the post that gets reshared. + +--- + +## Step 4 · RAG "Ask this book" + +Goal: user reading DDIA can tap `Ask` in reader → chat that answers from (a) chapters they've read so far, (b) their own highlights/notes across all books. With citations and jump-to-chapter. + +### Constraints / rules + +- **Hand-rolled in C# with Npgsql.** No LangChain / LlamaIndex / agents. Two SQL queries + one prompt. Showing you understand RAG is more impressive than importing it. +- **Spoiler-safe**: only retrieve from chapters with `reading_progress >= chapter_end`. +- **Private corpus per user**: user highlights + notes are part of retrieval (unique angle nobody else has). + +### Stack + +- **pgvector** on existing Postgres (one migration, no new infra) +- **Embeddings**: `nomic-embed-text` via existing Ollama (free, local, portfolio bonus). Fallback to `text-embedding-3-small` if Ollama unavailable. +- **Chunking**: paragraph-level with 50–100 word window, 1 sentence overlap. Store `embedding`, `chapter_id`, `paragraph_index`, `text`. +- **Hybrid search**: existing Postgres FTS + cosine similarity, combined via **Reciprocal Rank Fusion** (`RRF score = sum(1 / (k + rank_i))`, k=60). Blog-post-worthy talking point. +- **LLM answer**: gpt-5-mini, streamed via SSE. + +### Schema additions + +```sql +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE chapter_embeddings ( + id BIGSERIAL PRIMARY KEY, + chapter_id UUID NOT NULL REFERENCES chapters(id) ON DELETE CASCADE, + paragraph_ix INT NOT NULL, + text TEXT NOT NULL, + embedding VECTOR(768) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX ON chapter_embeddings USING hnsw (embedding vector_cosine_ops); + +CREATE TABLE highlight_embeddings ( + id BIGSERIAL PRIMARY KEY, + highlight_id UUID NOT NULL REFERENCES highlights(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + embedding VECTOR(768) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +### Retrieval flow + +``` +1. Embed user question via Ollama nomic-embed-text +2. Two retrievals (parallel): + a) chapter_embeddings WHERE chapter_id IN (already_read_chapters_for_user_in_book) + ORDER BY embedding <=> query LIMIT 20 + b) Postgres FTS on chapter.plain_text scoped to same chapters +3. RRF combine → top 5 chunks +4. Same retrieval against highlight_embeddings WHERE user_id = current_user + → top 3 personal highlights +5. Prompt gpt-5-mini with: question + 5 chunks + 3 highlights + instruction + "answer only from provided context, cite chapter+paragraph" +6. Stream response via SSE; client renders citations as jump-to-chapter links +``` + +### Eval setup + +- 30 hand-crafted Q&A pairs against DDIA +- Metrics: `recall@5` (did right chunk show up?), `faithfulness` (LLM-as-judge: does answer rely on retrieved context?), `latency p95` +- Run on every prompt or retrieval change + +### Acceptance + +- Ask answers grounded questions from DDIA with chapter citations +- Spoilers blocked (verified by test) +- Personal highlights surface when relevant +- Eval suite green +- Streaming UI on web + mobile + +--- + +## Step 5 · Podcast voice upgrade (optional, post-launch) + +Swap `ITtsService` Edge implementation → ElevenLabs or OpenAI `tts-1-hd` behind a flag for podcast generation only (regular TTS stays on Edge — it's free and fine for in-reader use). + +- ElevenLabs: ~$1.50 per 30-min podcast, NotebookLM-level quality +- OpenAI `tts-1-hd`: ~$0.15 per 30-min podcast, decent quality + +Trigger only when worth it — e.g. for featured books, or as a paid tier. + +--- + +## What's explicitly NOT in this plan + +- **LangChain / LlamaIndex / agent frameworks** — hand-rolled is more impressive in 2026. +- **General "chat with any book"** — breaks "deep reading" positioning, weakens the spoiler-safe story. +- **Multi-modal (images, video)** — out of scope. +- **Voice cloning of the user / custom narrators** — fun, but adds zero portfolio weight. +- **Fine-tuning custom models** — wrong layer for this project. + +--- + +## Portfolio talking points (collected for resume / interviews) + +After this plan is done: + +1. **Production AI system in C#/.NET** with 7+ LLM surfaces, multi-model architecture (OpenAI + Ollama + Claude CLI), prompt injection defense, audit trail with immutable replay. +2. **Observability + eval pipeline** — every prompt change gated by regression tests with LLM-as-judge. +3. **Hand-rolled RAG** with pgvector, hybrid retrieval via RRF, private per-user corpus, spoiler-safe scoping. +4. **Audio AI pipeline** — content summarization → dialogue generation → multi-voice synthesis → FFmpeg post-processing, all from existing infra. +5. **Real users on real product** — Google Play app, paying customer target. + +This is the resume of someone who builds AI in production, not someone who imports it. + +--- + +## Open questions to answer before Step 3 starts + +- Pre-generate podcasts for the 15–20 curated AI-engineering corpus, or on-demand per book? +- Per-chapter podcasts (short, scoped) vs whole-book podcasts (long, big-picture)? — probably both, start with whole-book. +- Free for all users, or gated to logged-in / paid? +- Transcript-as-SEO: serve `podcast-script.json` rendered as HTML for SEO crawlers? (likely yes — free SEO win) diff --git a/SEO_FIX_TASK.md b/SEO_FIX_TASK.md new file mode 100644 index 00000000..c27f91db --- /dev/null +++ b/SEO_FIX_TASK.md @@ -0,0 +1,182 @@ +# SEO Index Drop — Investigation Task for Claude Code + +**Context for Claude Code**: Vasyl ran a Cowork analysis on 2026-05-19 against Ahrefs Site Audit, Google Search Console, and the live site. Index dropped sharply after **2026-05-12**. This document is the handoff — it lists symptoms, the root cause hypothesis, and the exact files/commands to verify and fix. + +## Symptoms (observed, not assumed) + +1. **GSC `Page indexing` (textstack.app, last update 2026-05-14)** + - Indexed: **331** + - Not indexed: **3,320** across 11 reasons + - Top reasons: + - `Excluded by 'noindex' tag` — 2,112 (mostly reader/library — intentional) + - `Crawled — currently not indexed` — **639** (Google quality demotion) + - `Not found (404)` — **149** + - `Page with redirect` — 108 + - `Soft 404` — **83** + - `Server error (5xx)` — 76 + - `Duplicate, Google chose different canonical` — 65 + +2. **Ahrefs Site Audit (crawl 2026-05-19)** + - `404 page` — **130**, all are author URLs e.g. `/en/authors/william-makepeace-thackeray/`, `/en/authors/d-h-lawrence/`, `/en/authors/arnold-bennett/`, `/en/authors/margaret-oliphant/`, `/en/authors/ambrose-bierce/`, `/en/authors/kenneth-grahame/`, `/en/authors/ethel-voynich/`, `/en/authors/j-j-connington/`, `/en/authors/thomas-de-quincey/`, `/en/authors/harry-harrison/` (and 120 more) + - All 130 are linked from `/en/books/...` detail pages + - `Page has broken JavaScript` — **1,815** referencing `/assets/index-Dj8T4aeH.js` (status was 404 during Ahrefs crawl, **200 now** → bundle hash changed during deploy and stale SSG HTML still referenced old name) + - `Duplicate pages without canonical` — 7 + - `Noindex page` — 1,412 (consistent with reader/library noindex routes; not the issue) + +3. **Live HTTP behavior verified from browser fetch (2026-05-19)** + - `https://textstack.app/en/books/dracula/` → 200, **`X-SEO-Render: spa`** — but `CLAUDE.md` says this URL MUST return `X-SEO-Render: ssg`. + - Same for `/en/authors/jane-austen/`, `/en/authors/`, `/en/genres/`. Every URL tested returned `spa` even with `User-Agent: Googlebot`. + - **Note**: browser `fetch()` may not propagate custom `User-Agent` headers — re-test with `curl -H "User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"` from a host that can reach the production server, NOT from inside Docker. + +4. **Sitemap vs internal links mismatch** + - `https://textstack.app/sitemaps/authors.xml` → only **56 authors** + - But book detail pages link to **130+ author URLs** that are NOT in the sitemap + - `william-makepeace-thackeray` is reachable in the browser (SPA renders it fine — there's an Author entity in the DB) but is not in the authors sitemap + +## Root-cause hypothesis (in priority order) + +### H1 — SSG dist is stale or missing for many authors (HIGH confidence) + +`infra/nginx/textstack.conf` routes bots through: +```nginx +location ~ ^/en/authors/[^/]+/?$ { + add_header X-SEO-Render $seo_render_tag always; + try_files $ssg_file @spa; # $ssg_file = /ssg$uri/index.html for bots +} + +location @spa { + if ($is_bot) { + return 404 '...'; # ← HARD 404 by design when SSG file missing + } + ... +} +``` + +If `data/ssg/en/authors/william-makepeace-thackeray/index.html` does not exist, Ahrefs/Googlebot get a real HTTP 404. This is **by design** (comment says "prevents Google Soft 404") but combined with author URLs being linked from book pages while NOT having SSG generated, it bleeds 404s. + +**Verify on the server**: +```bash +ssh into prod, then: +ls /home/vasyl/projects/onlinelib/textstack/data/ssg/en/authors/ | wc -l +ls /home/vasyl/projects/onlinelib/textstack/data/ssg/en/authors/william-makepeace-thackeray/index.html +# Also compare to authors that ARE in the sitemap: +ls /home/vasyl/projects/onlinelib/textstack/data/ssg/en/authors/jane-austen/index.html + +# Check ssg-worker is running +docker compose ps ssg-worker +docker compose logs --tail 200 ssg-worker + +# Check the periodic rebuild worker (Api hosts it: SsgPeriodicRebuildWorker) +docker compose logs api 2>&1 | grep -i ssg | tail -50 +``` + +Then check what changed in git between 2026-05-11 and 2026-05-19: +```bash +cd /Users/vasylvdovychenko/projects/textstack/textstack +git log --since=2026-05-11 --until=2026-05-19 --oneline +git log --since=2026-05-11 --until=2026-05-19 -- apps/web/scripts/prerender.mjs backend/src/Api/Services/SsgPeriodicRebuildWorker.cs infra/nginx/textstack.conf +``` + +### H2 — Book detail pages link to unpublished authors (HIGH confidence) + +`apps/web/sitemaps/authors.xml` only lists 56 published authors, but `BookDetailPage.tsx` (or wherever book→author links render) emits links to **all** authors associated with editions — including unpublished ones. Those orphan author URLs get crawled, hit `@spa`, and return 404 to bots. + +**Investigate**: +```bash +# Find where book pages render author links +grep -rn "authors/" apps/web/src/pages/ apps/web/src/components/ | grep -v "node_modules" | head -30 + +# Find sitemap generation logic (likely in Api) +grep -rn "sitemap" backend/src/Api/Endpoints/ | head -20 + +# Compare: which editions have authors that aren't in the sitemap? +# SQL on prod: +docker compose exec db psql -U app books -c " +SELECT a.slug, a.name, e.status AS edition_status +FROM authors a +JOIN edition_authors ea ON ea.author_id = a.id +JOIN editions e ON e.id = ea.edition_id +WHERE e.status = 'Published' + AND a.slug NOT IN ( + SELECT a2.slug FROM authors a2 + JOIN edition_authors ea2 ON ea2.author_id = a2.id + JOIN editions e2 ON e2.id = ea2.edition_id + WHERE e2.status = 'Published' + GROUP BY a2.slug + -- Whatever filter the sitemap uses + ); +" +# (Refine the query against the actual sitemap query in backend code.) +``` + +### H3 — Stale SSG bundle reference from a deploy on/around 2026-05-12 (MEDIUM) + +Ahrefs found 1,815 pages referencing `/assets/index-Dj8T4aeH.js` (404 during their crawl, 200 now). Vite emits hashed filenames; each rebuild changes the hash. If `make rebuild-ssg` runs from an old `apps/web/dist/` without rebuilding the SPA first, the prerendered HTML pins an old hash. On the next SPA rebuild the old `index-*.js` is gone → bots see broken JS → soft-404. + +**Investigate**: +```bash +git log --since=2026-05-11 --oneline -- Makefile apps/web/scripts/prerender.mjs .github/workflows/deploy.yml + +# Look at the deploy.yml sequence — does it build apps/web BEFORE rebuilding SSG? +cat .github/workflows/deploy.yml +``` + +## Suggested fix order (verify each before moving on) + +1. **`make rebuild-ssg`** on prod. Single command. Should regenerate every SSG file from current DB + current `apps/web/dist`. After it finishes: + ```bash + curl -sI -H "User-Agent: Googlebot" https://textstack.app/en/books/dracula/ | grep -i x-seo-render + # Expect: x-seo-render: ssg + curl -sI -H "User-Agent: Googlebot" https://textstack.app/en/authors/jane-austen/ | grep -i x-seo-render + # Expect: x-seo-render: ssg + ``` + If this alone restores `ssg`, the root cause was either a missed rebuild or a deploy-order bug. + +2. **Stop emitting orphan author links.** In whatever component renders the author chip/link on book pages, only render `` when the author has a published page (or check the same condition the sitemap uses). Otherwise render plain text. File to inspect first: `apps/web/src/pages/BookDetailPage.tsx`, and any author-link sub-component. + +3. **Add publish gating to authors mirror what sitemap uses.** Decide a single source of truth (probably `IsPublished` flag on Author entity OR `EXISTS(published edition)` check), then use it in three places: (a) sitemap generator, (b) SSG prerender list in `apps/web/scripts/prerender.mjs`, (c) book→author link rendering. + +4. **Re-examine `@spa` hard-404 for bots.** The comment claims it prevents soft-404, which is correct for a bot hitting a *truly* nonexistent URL. But it's currently firing for *valid* URLs that just don't have SSG yet. Two safer options: + - Make `@spa` for bots return a properly rendered minimal HTML with the page's H1 + canonical (i.e., serve the SPA shell but with server-injected `
{inline}
"); break; } - - plainBuilder.AppendLine(element.Text); } } diff --git a/backend/src/Extraction/TextStack.Extraction/Extractors/PdfTextExtractor.cs b/backend/src/Extraction/TextStack.Extraction/Extractors/PdfTextExtractor.cs index 1566ba1b..463dcc8d 100644 --- a/backend/src/Extraction/TextStack.Extraction/Extractors/PdfTextExtractor.cs +++ b/backend/src/Extraction/TextStack.Extraction/Extractors/PdfTextExtractor.cs @@ -141,14 +141,18 @@ private static ExtractionResult ExtractFromDocument( var chapter = chapters[chapterIdx]; var chapterNumber = chapterIdx + 1; + // TOC almost always sits in the front half of the book; Index / + // Glossary in the back. The guard prevents an Italian/Spanish + // "Indice/Índice" — same word means "Index" at the back and + // "TOC" at the front — from being mis-dropped when it's the Index. + var isFrontHalf = chapterIdx * 2 < chapters.Count; // Drop TOC chapters at extraction time. PDF TOCs come out as one // dense run of leader-dotted entries and we already build the - // reader-side TOC from the chapter list itself. Guard: never drop - // the only chapter — a single-chapter book literally titled - // "Contents" would otherwise vanish entirely (paranoid edge case - // raised in PR #244 bug report). - if (chapters.Count > 1 && FrontMatterFilter.IsTableOfContents(chapter.Title)) + // reader-side TOC from the chapter list itself. Two guards: + // • single-chapter book (don't disappear the only content); + // • chapter is in the front half (see ambiguous-word note above). + if (chapters.Count > 1 && isFrontHalf && FrontMatterFilter.IsTableOfContents(chapter.Title)) { warnings.Add(new ExtractionWarning( ExtractionWarningCode.ContentFiltered, @@ -201,6 +205,33 @@ private static ExtractionResult ExtractFromDocument( // genuine cross-chapter repetition like author bylines. var filteredPageElements = FilterRunningHeaders(pageElements); + // Content-level TOC drop happens BEFORE HTML conversion so: + // (a) we don't waste the HtmlCleaner pipeline on a chapter + // we're about to throw away, and + // (b) the detector works on raw paragraph texts — decoupled + // from PdfToHtmlConverter's markup choices. Three guards: + // • single-chapter book (don't disappear the only content); + // • chapter is in the back half (Index/Glossary live there + // and look exactly like TOC by content); + // • bookmark title matches a known back-matter section + // (Index, Glossary, Bibliography, …). + if (chapters.Count > 1 + && isFrontHalf + && !FrontMatterFilter.IsKnownBackMatter(chapter.Title)) + { + var paragraphTexts = filteredPageElements + .SelectMany(pe => pe.Elements) + .Where(e => e.Type == TextElementType.Paragraph) + .Select(e => e.Text); + if (FrontMatterFilter.LooksLikeTableOfContentsBody(paragraphTexts)) + { + warnings.Add(new ExtractionWarning( + ExtractionWarningCode.ContentFiltered, + $"Skipped Table of Contents chapter (content-detected): {chapter.Title}")); + continue; + } + } + // Convert to HTML var (html, plainText) = PdfToHtmlConverter.ConvertPages(filteredPageElements); if (string.IsNullOrWhiteSpace(plainText)) diff --git a/backend/src/Infrastructure/Services/LocalFileStorageService.cs b/backend/src/Infrastructure/Services/LocalFileStorageService.cs index e5474a7e..b691d254 100644 --- a/backend/src/Infrastructure/Services/LocalFileStorageService.cs +++ b/backend/src/Infrastructure/Services/LocalFileStorageService.cs @@ -103,6 +103,12 @@ public Task DeleteUserDirectoryAsync(Guid userId, CancellationToken ct = default return Task.FromResult
+```
+
+The current alt text is still accurate for the new hero, so **no string change needed** — just confirm the image renders correctly when README is viewed. If you want sharper alignment, optionally tighten alt to:
+
+```markdown
+
+```
+
+…but this isn't required. The existing alt is fine.
+
+## Out of scope
+
+- Don't re-record the GIF in a different language. Keep Spanish — it's deliberate (politically neutral, broad audience, demonstrates non-English target).
+- Don't move the GIF file to `docs/assets/` — README path expects `docs/demo.gif` and we shouldn't churn paths.
+- Don't compress the GIF further — 190 KB at 720p × 4.5s is already a good balance; smaller would degrade text legibility.
+- Don't restore the old classical-literature hero from `hero-old-classical.png` — it represents stale positioning, kept only as an audit-trail backup.
diff --git a/docs/fixes/tap-on-word-and-explain.md b/docs/fixes/tap-on-word-and-explain.md
new file mode 100644
index 00000000..94da9e60
--- /dev/null
+++ b/docs/fixes/tap-on-word-and-explain.md
@@ -0,0 +1,162 @@
+# Fix: domain-aware tap-on-word translation + Explain 404 + broken book title
+
+This PR addresses three production bugs observed on textstack.app that together break the core "context-aware reader" promise from the README. Tackle in one PR — they're all in the same surface area.
+
+## Bug 1 — Tap-on-word translation is dictionary-grade, not domain-aware
+
+### Observed
+- DDIA, tap "polling" with target Russian → popup shows `опросы` (electoral-polls meaning, wrong domain)
+- DDIA chapter on data systems, tap "warehouse" with target Portuguese → `armazém` + dictionary def "A place for storing large amounts of products. In logistics, a place where products go to from the manufacturer..." — wrong domain, despite an explicit "Data warehouse" diagram in the same paragraph
+
+### Root cause
+- `apps/web/src/lib/wordBubbleFetch.ts:61` — `translateApi(word, bookLanguage, targetLang, signal)` is called with **no book context**.
+- `apps/web/src/api/translation.ts:14–28` — sends only `{text, sourceLang, targetLang}`, no `editionId`/`bookId`/`sentence`.
+- `backend/src/Api/Endpoints/TranslationEndpoints.cs:71–72` — current system prompt:
+ ```csharp
+ $"You are a translation engine. Translate from {srcLang} to {tgtLang}. " +
+ "Output ONLY the translated text. No preface, no quotes, no explanation."
+ ```
+ No domain hint, no genre, no surrounding sentence. OpenAI defaults to the most common everyday meaning.
+
+### Fix
+
+**Backend `TranslationEndpoints.cs`:**
+
+1. Extend `TranslateRequest` to optionally accept `Guid? BookId`, `string? Sentence`, `string? Genre`.
+2. Mirror the genre-lookup pattern from `ExplainEndpoints.cs:44–65` — when `BookId` is present and `Genre` is null, look up genre from `Editions` first then `UserBooks`. Wrap in try/catch, log warning on failure, fall back to "general".
+3. Replace the system prompt with:
+ ```csharp
+ var domainHint = string.IsNullOrWhiteSpace(genre)
+ ? ""
+ : $"Domain hint: {genre.Trim()}. Prefer the domain-specific meaning over the everyday meaning when the word is ambiguous. ";
+
+ var sentenceCtx = string.IsNullOrWhiteSpace(sentence)
+ ? ""
+ : $"Sentence context: \"{sentence.Trim()}\". ";
+
+ var systemPrompt =
+ $"You are a translation engine for readers of technical books. " +
+ $"Translate from {srcLang} to {tgtLang}. " +
+ domainHint +
+ sentenceCtx +
+ "Output ONLY the translation. " +
+ $"If the word has a domain-specific meaning that differs from its everyday meaning, " +
+ $"append a SHORT clarifier in {tgtLang} parentheses, e.g. " +
+ $"\"увага (механізм у нейромережах)\" or \"опитування (періодичний запит до сервера)\". " +
+ "Otherwise output just the translation. No preface, no quotes, no markdown.";
+ ```
+ The parenthetical-clarifier pattern is what the README explicitly promises (`увага (механізм у нейромережах)`). Make sure the prompt encourages it.
+4. Extend the cache key to include `genre` (or domain bucket). Otherwise the first-translated word poisons the cache for all readers across all genres.
+
+**Frontend `apps/web/src/lib/wordBubbleFetch.ts` + `apps/web/src/api/translation.ts`:**
+
+1. Update `translate()` signature to accept optional `bookId` and `sentence`.
+2. In `fetchWordBubble()`, extract the surrounding sentence using the same logic that `ReaderHighlights.tsx` already uses to build the Explain payload (search the codebase for the existing helper — don't reimplement).
+3. Pass the current book's id to the call. For curated books that's the `editionId`, for user books that's the `userBookId` — pass whichever is in scope, the backend already handles both via the `Editions` → `UserBooks` cascade.
+4. When called from contexts without book scope (preview mode, marketing landing widget, etc.), omit the new fields — the backend gracefully falls back to context-free behavior.
+
+## Bug 2 — `/explain` returns 404 in production
+
+### Observed
+On user-uploaded DDIA, select sentence with "polling" → click 💡 (Explain) → popup shows `Explain failed: 404`. Translation on the same selection works fine, so it's not a generic api outage.
+
+### Root cause (suspected)
+`backend/src/Api/Endpoints/ExplainEndpoints.cs:15` registers only the bare `/explain` route:
+```csharp
+var group = app.MapGroup("/explain").WithTags("Explain");
+group.MapPost("", Explain).WithName("Explain").RequireRateLimiting("explain");
+```
+
+Compare with `TranslationEndpoints.cs:13–19`:
+```csharp
+var group = app.MapGroup("/api/translate").WithTags("Translation");
+group.MapPost("", Translate).WithName("Translate").RequireRateLimiting("translate");
+group.MapGet("/languages", GetLanguages).WithName("GetTranslationLanguages");
+
+// Also map without /api/ prefix for nginx compatibility
+app.MapPost("/translate", Translate).WithTags("Translation").WithName("TranslateCompat").RequireRateLimiting("translate");
+```
+
+Translation has dual-registration and a dedicated nginx location at `infra/nginx/textstack.conf:189`. Explain has neither. Likely a regression where Explain wasn't migrated when the dual-registration pattern was added, and a build of the frontend went out without `VITE_API_URL=/api` (or with it and nginx didn't have the explicit location to make the catchall work as expected).
+
+### Fix
+
+**Backend `ExplainEndpoints.cs`:**
+
+Mirror `TranslationEndpoints.cs:13–19` exactly. Register Explain at both `/api/explain` and `/explain`:
+
+```csharp
+public static void MapExplainEndpoints(this WebApplication app)
+{
+ var group = app.MapGroup("/api/explain").WithTags("Explain");
+ group.MapPost("", Explain).WithName("Explain").RequireRateLimiting("explain");
+
+ // Also map without /api/ prefix for nginx compatibility
+ app.MapPost("/explain", Explain).WithTags("Explain").WithName("ExplainCompat").RequireRateLimiting("explain");
+}
+```
+
+**Frontend `apps/web/src/api/explain.ts`:**
+
+Change the URL to be consistent with `translation.ts`:
+```ts
+const res = await fetch(`${API_BASE}/api/explain`, { ... })
+```
+Update the file's leading comment to match — the "Don't add `/api/` here" warning is misleading now.
+
+**Nginx `infra/nginx/textstack.conf`:**
+
+Optional but consistent — add a dedicated location with explain rate limit, mirroring `/api/translate` block at line 189:
+```nginx
+location /api/explain {
+ limit_req zone=explain_limit burst=2 nodelay;
+ proxy_pass http://textstack_api/explain;
+ # ... copy headers from /api/translate block
+}
+```
+Add `limit_req_zone ... zone=explain_limit:10m rate=20r/m;` near the other zones at the top of the file.
+
+### Verification
+
+After fix, on textstack.app:
+1. Open any user-uploaded book → reader → select a sentence → click 💡 → expect 200 with 2-3 sentence explanation in target language.
+2. Repeat on a curated library book → expect same behavior.
+3. Hit `/api/explain` and `/explain` directly with curl — both should accept POST and return identical results.
+
+## Bug 3 — Broken title `(for )` on book detail page
+
+### Observed
+Book detail page header reads **"Designing Data-Intensive Applications (for )"** with empty parentheses. Visible on user-uploaded DDIA (URL pattern `/library/my/{id}/`). Reader header carries the same broken title forward.
+
+### Suspected root cause
+A template like `"{title} (for {targetLanguage})"` or `"{title} (for {audience})"` with empty interpolation when the field is missing. Search for the literal `"(for "` or `(for {` in `apps/web/src/pages/` (likely `BookDetailPage.tsx` or user-book equivalent — given URL `/library/my/...` it's the user-book detail page, possibly `apps/web/src/pages/UserBookDetailPage.tsx` or similar).
+
+### Fix
+Conditional render: if the interpolated field is empty/null, omit the `(for )` segment entirely. Don't render an empty placeholder.
+
+### Verification
+Open `https://textstack.app/en/library/my/{any-user-book-id}/` — title should be `Designing Data-Intensive Applications` with no trailing parenthetical when the relevant field is empty.
+
+## Verification — overall
+
+After Bug 1 and Bug 2 fixes deploy, on textstack.app, with target Russian:
+
+| Word | Book | Expected |
+|------|------|----------|
+| polling | DDIA, distributed systems chapter | `опрос (периодический запрос к серверу)` or similar |
+| warehouse | DDIA, ETL chapter | `хранилище (хранилище данных)` or equivalent gloss |
+| attention | any ML book | `внимание (механизм в нейросетях)` |
+| eventual consistency | DDIA | `конечная согласованность` |
+| polling | a poll-related news article (different genre) | `опросы` (everyday meaning preserved when domain doesn't suggest otherwise) |
+
+The last row matters — make sure the domain hint *biases* the model but doesn't force technical meaning when the genre is wrong (e.g. if a user uploads a book of poetry, "warehouse" should still mean storage building).
+
+For Bug 2: `/api/explain` and `/explain` both accept POST on textstack.app, both return identical results, neither 404s on user-uploaded books.
+
+For Bug 3: book detail page never renders an empty `(for )` parenthetical.
+
+## Out of scope
+
+- Replacing Free Dictionary API entirely — keep the dictionary popup separate, it's the secondary fallback. Bug 1 fix only addresses the LLM-translation half of the popup.
+- Translation latency / cost — `gpt-5-mini` handles word-level translations cheaply; no architecture change.
+- The `Words read=0 / Sessions=7 / Reading time=5m` stats inconsistency on the same page (separate ticket — file separately).
diff --git a/docs/fixes/x-profile-banner.md b/docs/fixes/x-profile-banner.md
new file mode 100644
index 00000000..7808fa89
--- /dev/null
+++ b/docs/fixes/x-profile-banner.md
@@ -0,0 +1,120 @@
+# Task: generate subtle X profile banner
+
+Generate a 1500×500 PNG banner for X (Twitter) profile @Rexetdeus. Output: `docs/assets/x-banner.png`.
+
+## Constraints
+
+- **Dimensions: exactly 1500 × 500 px** (X banner aspect, displays full on most screens)
+- **Filesize:** under 500 KB (X has a 2 MB limit but smaller renders faster)
+- **NOT promo:** no big logos, no GitHub URL, no "TextStack" plastered across. The banner should signal "thoughtful dev with taste" — not "buy my product".
+- Tone: understated, dev-aesthetic, the kind of banner a senior engineer with a quiet brand would have.
+
+## Aesthetic — terminal/editor with a single-line comment
+
+Dark muted background, monospace text, faded code-comment color. Like glancing at an editor where someone left one self-aware line of code.
+
+### Visual spec
+
+| Element | Spec |
+|---|---|
+| Background | Solid `#0d1117` (GitHub dark theme bg) OR subtle vertical gradient from `#0d1117` to `#161b22` |
+| Text content (pick one — see options below) | A single code-comment line |
+| Font | JetBrains Mono if available (`apt-get install fonts-jetbrains-mono` or download from JetBrains site). Fallback: `DejaVu Sans Mono`, then any monospace |
+| Font size | 28–32 px (subtle but legible on most viewports) |
+| Text color | `#6e7681` (GitHub comment color — muted, not screaming) |
+| Text position | Left-aligned with 90 px left margin, vertically centered |
+| Optional decoration | A faint blinking-cursor-style block `▍` at end in slightly brighter `#8b949e`. Just one character. |
+| Padding around text | Generous — empty space carries the design |
+
+### Tagline options — pick the best one (or generate variants and let user choose)
+
+```python
+TAGLINE_OPTIONS = [
+ "// reading, in production.", # plays on "X in production" trope + TextStack
+ "// notes on books I keep quitting.", # self-deprecating, honest
+ "// DDIA, attempt #4. shipping the reader that fixes it.", # specific story
+ "// some books read you back.", # introspective, no product mention
+ "// .NET. React Native. Books I never finish.", # 3-element stack signature
+]
+```
+
+**Default recommendation:** `// reading, in production.` — it ties to the Gemma 4 post-mortem article (LLM in production) while staying ambiguous enough to NOT read as promo. Devs who land on the profile get the wink; others see a stylized tagline.
+
+If unsure, generate ALL FIVE as separate files (`docs/assets/x-banner-v1.png` … `v5.png`) and let user pick.
+
+## Implementation
+
+Use Python with Pillow (PIL). Suggested approach:
+
+```python
+from PIL import Image, ImageDraw, ImageFont
+
+WIDTH, HEIGHT = 1500, 500
+BG = "#0d1117"
+TEXT_COLOR = "#6e7681"
+CURSOR_COLOR = "#8b949e"
+TAGLINE = "// reading, in production."
+
+img = Image.new("RGB", (WIDTH, HEIGHT), BG)
+draw = ImageDraw.Draw(img)
+
+# Try preferred fonts in order
+font_candidates = [
+ "/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
+ # ... fall back to whatever ImageFont.load_default()
+]
+font = None
+for path in font_candidates:
+ try:
+ font = ImageFont.truetype(path, 30)
+ break
+ except Exception:
+ continue
+if font is None:
+ font = ImageFont.load_default()
+
+# Vertical center
+bbox = draw.textbbox((0, 0), TAGLINE, font=font)
+text_h = bbox[3] - bbox[1]
+y = (HEIGHT - text_h) // 2
+
+draw.text((90, y), TAGLINE, fill=TEXT_COLOR, font=font)
+
+# Optional cursor block at end
+text_w = bbox[2] - bbox[0]
+draw.text((90 + text_w + 14, y), "▍", fill=CURSOR_COLOR, font=font)
+
+img.save("docs/assets/x-banner.png", optimize=True)
+```
+
+Adjust as needed if Pillow isn't available — use `cairosvg` or generate SVG and rasterize. Whatever works.
+
+## Verification
+
+After generation:
+
+1. Open the PNG and confirm:
+ - Dimensions exactly 1500×500
+ - Filesize < 500 KB
+ - Text is legible but quiet (NOT screaming)
+ - No accidental TextStack/GitHub/promo branding
+2. Render at 760×254 (X displays roughly half-size on most viewports) — text should still be readable.
+3. If multiple variants generated, save all and list them in the commit message so user can choose by filename.
+
+## Out of scope
+
+- Don't add the TextStack logo, name, or URL to the banner.
+- Don't use bright/saturated colors — the whole palette should feel like a dev tool dark theme.
+- Don't put images of books, screenshots, or product visuals. Pure typographic banner.
+- Don't generate multiple aspect ratios — X only needs 1500×500 for the profile banner.
+
+## Commit message suggestion
+
+```
+chore(marketing): add subtle X profile banner
+
+1500x500 PNG, JetBrains-Mono code-comment aesthetic.
+Tagline: "// reading, in production."
+No product branding — banner reads as quiet dev profile, not promo.
+```
diff --git a/docs/marketing/campaign-tracker.md b/docs/marketing/campaign-tracker.md
new file mode 100644
index 00000000..64b99c1b
--- /dev/null
+++ b/docs/marketing/campaign-tracker.md
@@ -0,0 +1,356 @@
+# TextStack Marketing Campaign — Live Tracker
+
+Last updated: 2026-05-21 (Thursday, post-routine + posting session)
+
+## Daily X routine log
+
+| Date | Candidates drafted | Posted | Notes |
+|---|---|---|---|
+| 2026-05-12 | 2 | 1 posted | Manual first-run trigger. **Posted reply on @simonw "30GB Mac memory" post (64.5K views, 540 likes)** — 30GB number tie-in with our gemma4:e2b production deployment. Feed quality issue noted (Ferarri Prime growth-hack reposts). File: `docs/marketing/x-routine/2026-05-12.md` |
+| 2026-05-13 | 3 fresh + 1 continued | pending | Following feed still dominated by mutual-follow bait; pivoted to live search + tribe scan. Top pick: **@im_yeyito** "llama.cpp eval path / vibes vs data" (direct ask for the prod numbers we have). Other candidates: @VladimirVivien (Gemma 4 2B CPU), @dotnet (Agent Framework 1.0 MCP). Continued: @PaulChen088 on Synthadoc. File: `docs/marketing/x-routine/2026-05-13.md` |
+| 2026-05-14 | 3 fresh + 1 continued | **4 posted** | Following feed *still* bait-dominated (3rd session). Live search again carried the load. **All 4 fresh candidates posted with user approval:** @MozillaAI (Gemma 4 / llamafile — included TextStack prod-numbers mention), @TheWordWeaver_ (framer-motion ESM debug fix), @RahulGangwani24 (Ollama latency counter-perspective), @asiokun3 (JP, base_url swap gotcha). Paul Chen continued conversation deferred (yesterday's draft handles it). Account at 11 followers (+9 since baseline). File: `docs/marketing/x-routine/2026-05-14.md` |
+| 2026-05-15 | 4 fresh + 1 continued (3rd carry-over) | **3 posted, 1 blocked** | Following feed bait-dominated (4th session). Live search + tribe scan (@simonw, @theo, @karpathy, @arvidkahl) carried. **Posted with user approval:** @MoureDev (Local AI workshop — TextStack prod-numbers mention), @aterrel (Spark DGX inference-stack question), @ollies0x (3090 vs cloud + CPU-VPS third path). **@swyx skipped** — post had "Only some accounts can reply" restriction; also chart on inspection showed $445M ARR now, so the drafted $15B EOY guess was off by ~10x — would have looked like we didn't read the chart. Lesson: always inspect attached charts before drafting a number-specific reply. Paul Chen on 3rd carry-over — still pending decision. New tribe watchlist adds: @mudler_it (LocalAI maintainer), @MoureDev (verified, EN/ES). File: `docs/marketing/x-routine/2026-05-15.md` |
+| 2026-05-18 | 5 fresh + 1 continued (4th carry-over) | pending | First Monday session after weekend skip. Following feed loaded after Retry; mix of @levelsio social-commentary + crypto/CoinMarketCap noise — live search (`"local LLM" OR ollama OR gemma`) carried the harvest. **Top pick:** @cwwhitehead asking @tobi "Why qwen and not Gemma 4?" — direct hit for TextStack qwen→gemma4 migration numbers (carries the daily prod-numbers mention). Other candidates: @tallhamn (reply to @antirez on local-AI middleware glue), @levelsio (3D terrain on Hoodmaps — technical implementation question), @dotnet (async patterns — ConfigureAwait take), @VitalikButerin (AI + formal verification spec gap). Paul Chen on 4th carry-over — still unattended. Account at 10 followers (+8 since baseline, flat vs last session). New tribe watchlist adds: @antirez (Tier E), @cwwhitehead (Tier B), @tallhamn (Tier C). File: `docs/marketing/x-routine/2026-05-18.md` |
+| 2026-05-19 | 4 fresh + 1 continued (5th carry-over) | pending | Following feed still crypto-heavy (CoinMarketCap, Bitcoin posts dominated top of feed) — live search (`ollama lang:en min_faves:5` and `"local LLM" OR gemma OR ollama OR "Claude Code"`) carried the harvest again. **Top pick:** @levelsio "ask Claude Code to audit your devices" — fresh (~1h), Tier A, high reply visibility. **TextStack prod-numbers mention:** @DAlistarh's new GSQ quantization paper (CPU-only deployment angle). Other candidates: @imikerussell (Claude Code → Home Assistant scenes loop, fresh thread), @JulianGoldieSEO (Local Agent Stack — Ollama glue debugging). Paul Chen on 5th carry-over — recommended skip (stale). Account at 8 followers (-2 vs last week — possibly unfollow churn; baseline +6). Suggestion in file: unfollow CoinMarketCap and Bitcoin to declutter Following tab, or move high-signal accounts to a List. File: `docs/marketing/x-routine/2026-05-19.md` |
+| 2026-05-20 | 4 fresh + 3 continued | pending | 6th session. Following tab still stale (Elon 22h / May-18 reposts / Karpathy 21h on top, nothing in the 1–3h window) — live search + direct tribe-profile scans (@simonw, @theo, @swyx, @levelsio, @arvidkahl) carried again. **Top pick:** @theo (~3h) on Gemini 3.5 Flash shipping a Jan-2025 knowledge cutoff — freshest, biggest dev story of the day. Other candidates: @simonw (Gemini 3.5 Flash 3x pricing), @arvidkahl (GitHub internal-repo breach / supply-chain, Tier A). **TextStack prod-numbers mention:** @DivyanshT91162 "local LLM needs a GPU" myth — caveat flagged in file: news-farm account, optional/skippable. **3 continued conversations** — first reciprocity pass on the May 14–15 replies: @TheWordWeaver_ ("thanks"), @ollies0x (substantive GPU-vs-VPS counter), @RahulGangwani24 (answered the Ollama-usage question) all replied back, all external. Paul Chen: still skip (stale, 6th carry-over, promo + link). Account at **6 followers (−2 vs May 19)** — churn continuing. File: `docs/marketing/x-routine/2026-05-20.md` |
+| 2026-05-21 | 4 fresh + 0 new continued (3 carry-overs) | **3 posted, 1 skipped** | 7th session. Following tab still stale (Elon 4h promo / 14–23h reposts, nothing in the 1–3h window) — live search carried again; @simonw scan confirmed tribe quiet (21h). **Posted with user approval:** @faradaymachines (Chrome Canary browser-level local inference), @noguchis (local-LLM-as-pre-screen-gate thread — carried the TextStack prod-numbers mention), @hiouso (build-in-public founder-metrics tool). **@NikkiSiapno skipped** — passed the search-preview check but the full post was a paid-partnership ad (#AtlassianPartner #Ad); user chose skip (no pure promo). Lesson #7 logged. No new reciprocity — @ollies0x / @TheWordWeaver_ / @RahulGangwani24 continued drafts from May 20 still pending; Paul Chen 7th carry-over, skip. **First posting since May 15** — user flagged the drafting-without-posting gap: May 18–21 drafts all went unposted and the account decayed 11→6; posting reconnected today as the fix. Account at 6 followers. File: `docs/marketing/x-routine/2026-05-21.md` |
+
+## Quick status
+
+| Metric | Value | Goal |
+|---|---|---|
+| External GitHub stars | **0** | 10+ |
+| Total channels active | 5 | — |
+| Open feedback loops | 4 | — |
+
+---
+
+## Channels
+
+### 🟢 X / Twitter — `@Rexetdeus`
+
+| Action | URL / Detail | Status | Metrics |
+|---|---|---|---|
+| Standalone post (DDIA + GIF) | [status/2053615432037257506](https://x.com/Rexetdeus/status/2053615432037257506) | ✅ Live, **pinned** to profile | 49 views, 2 reposts, 1 reply, 0 likes (last check ~13:00 May 11) |
+| Reply-to-self (GitHub CTA) | Reply on standalone post | ✅ Live | 1 like, 2 views (early check) |
+| Tier-1 reply to @1Umairshaikh | "What are you building this week" thread, 1.8K views | ✅ Live | Reply count 56→57 confirmed |
+| Engagement reply to @nabuhad (Inkett) | Smart question, no self-promo | ✅ Live | Deposit goodwill — Nabil reply pending |
+
+**Next action:** Monitor for replies. Don't add more replies in 24h (algo spam risk).
+
+---
+
+### 🟡 GitHub — `mrviduus/textstack`
+
+| Item | Status |
+|---|---|
+| Repo description | Updated by Claude Code (positions for devs/AGPL/local-LLM audience) |
+| Topics | react, open-source, postgres, react-native, dotnet, self-hosted, reading, spaced-repetition, agpl, epub, srs, aspnet-core, fb2, book-reader, ai-engineering, learning-tools, llm, kindle-alternative, pdf, expo |
+| README hero image | New `docs/assets/hero.png` — product screenshot of Explain popup on ETL in DDIA |
+| README demo gif | `docs/demo.gif` — 178 KB, cropped, no browser chrome |
+| **Total stars** | **4** (all self: mrviduus, r3xetdeus-bot, gl1tchmary, vasylvd) |
+| **External stars** | **0** |
+
+**Stargazers (all self, baseline reference):**
+- mrviduus (Pinnacle)
+- r3xetdeus-bot (joined Apr 18, 2026)
+- gl1tchmary (joined May 3, 2026)
+- vasylvd (joined May 23, 2023)
+
+**Next action:** Watch [stargazers page](https://github.com/mrviduus/textstack/stargazers) for new external entries.
+
+---
+
+### 🟢 Reddit — `r/SideProject`
+
+| Field | Value |
+|---|---|
+| Post URL | reddit.com/r/SideProject/comments/1ta9w9l/i_quit_designing_dataintensive_applications_three/ |
+| Author | r3xetdeus |
+| Posted | 2026-05-11 ~13:15 |
+| Sub size | 364K weekly visitors |
+| Status | ✅ Live (manual post by user) |
+| Metrics | Pending — first feedback usually 30-60min after post |
+
+**Next action:** Check upvotes/comments at +1h, +3h, +24h.
+
+---
+
+### 🔴 Hacker News — `viduus`
+
+| Item | Status |
+|---|---|
+| Account created | 2026-05-10 |
+| Profile | About + email filled — humanized |
+| Karma | 1 (started 1, no growth) |
+| Comment on Idempotency thread | ⚠️ **[flagged]** — invisible to default view |
+| Comment on Ask HN "What are you working on" | ⚠️ **[flagged]** — invisible to default view |
+| Email to `hn@ycombinator.com` (dang) | ✅ Sent 2026-05-10 evening — request to review/unflag |
+
+**Failure mode:** Karma=1 new account + product link in 1st comment + 2nd comment on front-page thread within 30 min → HN anti-spam fired.
+
+**Next action:** Wait for dang reply (6-24h typical). If unflagged on Idempotency comment — viduus gains legitimacy, can continue slow karma build. If silent ≥48h — abandon viduus, move on without HN this round.
+
+---
+
+### 🟡 Indie Hackers — (account exists)
+
+| Item | Status |
+|---|---|
+| Account login | ✅ Active |
+| Posting privileges | 🛑 **Gated** — must build "authentic contribution pattern" through comments, OR pay for IH Plus |
+| Comment 1 — Manish Bhusal "0 paying customers" thread | ✅ Posted 2026-05-11 ~14:00 |
+| Comment ID | `-OsN6Rm1U_iTlfvjGTyW` |
+
+**Strategy:** 1-2 thoughtful comments per day for 1-2 weeks → IH mods grant posting privilege. Then post TextStack as its own thread.
+
+**Next action:** Tomorrow add one more substantive comment on a different IH thread (different topic). Avoid posting >2 comments same day (spam-pattern risk).
+
+---
+
+### ⚪ dev.to — `mrviduus`
+
+| Item | Status |
+|---|---|
+| Existing article | "I quit Designing Data-Intensive Applications three times. Here's what I build on the fourth" — already published |
+| Follow-up article | ❌ Not yet drafted |
+
+**Next action:** Write `30 days later: shipping TextStack` follow-up (1500 words, DDIA continuation + tech stack + lessons learned). High passive leverage — dev.to articles live in Google index for months.
+
+---
+
+### ⚪ LinkedIn
+
+| Item | Status |
+|---|---|
+| Profile post | ❌ Not yet drafted |
+
+**Next action:** Short (~300 words) professional-tone post about shipping TextStack. Warm audience (former colleagues, recruiters) — high conversion potential per impression.
+
+---
+
+### ⚪ Personal DMs
+
+| Item | Status |
+|---|---|
+| List of dev contacts | ❌ Not assembled |
+| Template | ❌ Not drafted |
+
+**Next action:** Identify 5-10 dev contacts (LinkedIn / Telegram / Slack / X DMs). 50%+ star conversion expected. Highest signal-per-effort channel.
+
+---
+
+## Files created during campaign
+
+### Marketing assets
+- `docs/demo.gif` (178 KB, cropped, README-canonical)
+- `docs/assets/hero.png` (new product screenshot)
+- `docs/assets/hero-old-classical.png` (backup of old hero)
+- `docs/marketing/textstack-explain-short.mp4` (58 KB, 4.5s)
+- `docs/marketing/textstack-explain-short.gif` (178 KB)
+- `docs/marketing/textstack-explain-demo.mp4` (96 KB, 7.5s — longer for blog/dev.to)
+- `docs/marketing/textstack-explain-demo.gif` (421 KB)
+- `docs/marketing/textstack-demo.mp4` (older 31s version, kept for reference)
+- `docs/marketing/textstack-demo.gif`
+- `docs/marketing/textstack-srs-demo.mp4` (SRS-flashcard demo, alternative angle)
+- `docs/marketing/textstack-srs-demo.gif`
+
+### Playbooks & strategy
+- `docs/marketing/twitter-replies-playbook.md` — variants A-E + Tier-1 account list
+- `docs/marketing/campaign-tracker.md` — this file
+
+### Bug fix briefs (for Claude Code)
+- `docs/fixes/tap-on-word-and-explain.md` — domain-aware translation + Explain 404 + broken title
+- `docs/fixes/explain-404.md` — focused brief (later: turned out Explain works, brief obsolete)
+- `docs/fixes/readme-demo-gif.md` — README integration of new GIF + hero replacement
+
+---
+
+## Bug fixes status (product side)
+
+| Bug | Status | Notes |
+|---|---|---|
+| **#1 Tap-on-word not domain-aware** | ⚠️ Code OK, runtime partial | All 4 files in chain wired correctly. Live result still `warehouse → almacén` without clarifier. Suspected cause: user-book `Genre` field is NULL in DB (Ollama BookMetadataGenerator didn't populate). Diagnostic SQL: `SELECT id, title, genre FROM user_books WHERE title ILIKE '%data-intensive%';` |
+| **#2 Explain 404 on user books** | ✅ Resolved | Verified working in prod via user screenshot — was likely transient/selection-specific issue in initial test, not a real bug |
+| **#3 Broken `(for )` title** | ✅ Fixed and deployed | Claude Code created `BookTitleCleaner` utility + 2 EF migrations to clean existing titles at source |
+
+---
+
+## Action queue (priority order)
+
+### Today (May 11) — DONE, now in passive monitoring
+- [x] X campaign (done yesterday — 5 actions)
+- [x] HN email to dang (done yesterday)
+- [x] Reddit r/SideProject post (done — user manual)
+- [x] IH first comment on Manish/PostDew thread (done; comment ID `-OsN6Rm1U_iTlfvjGTyW`)
+- [x] Verified Hackernoon writing access (logged in, no gate — "Import Story" feature available)
+- [x] User polishing dev.to draft for tomorrow ("I shipped local LLM features two months ago. Production never ran them once." — Gemma 4 Challenge submission)
+- [ ] **Passive monitor only — no new actions today:**
+ - Email inbox for dang reply
+ - GitHub stargazers
+ - X notifications
+ - Reddit r/SideProject votes
+ - IH comment reaction from Manish
+
+### Wave 2 — May 12 — STATUS: ~85% complete
+
+**Executed today:**
+
+| Action | Status | Note |
+|---|---|---|
+| dev.to article publish | ✅ Live | "I shipped local LLM features two months ago..." Gemma 4 Challenge submission. 6 reactions in first 10 min (❤️🎉🤯👏🔥). 1 bookmark. URL: dev.to/mrviduus/i-shipped-local-llm-features-two-months-ago-production-never-ran-them-once-41g7 |
+| Hackernoon import + submit | ✅ Submitted | After URL import (which scraped dev.to chrome), user manually cleaned and submitted. 24-72h editorial review queue. Draft URL: app.hackernoon.com/mobile/6a0332e68f0929adca01637f |
+| r/selfhosted post | ✅ Submitted (user manual) | Title: "TextStack — open-source reader for tech books with local LLM features. AGPL-3.0, docker compose up, no GPU." |
+| X bio updated | ✅ Live | "Building TextStack — open-source reader for tech books you keep quitting (DDIA broke me 3 times). .NET/JS/RN. Indie ship logs + LLM in prod." |
+| X follow batch — 21 new follows | ✅ Done | Tier A-E mix (levelsio, swyx, karpathy, sindresorhus, dan_abramov, etc.). Following went 77 → 92. |
+| X reply on @theo (security psychosis) | ✅ Live | Post grew 1.6K → 3.6K views during session |
+| X reply on @masondrxy (Gemma 4 / Ollama / Deep Agents) | ✅ Live | Reposted by Harrison Chase/LangChain. Perfect topical match. |
+
+**Blocked:**
+
+| Channel | Block reason |
+|---|---|
+| r/LocalLLaMA | Sub-specific karma gate (0 karma in sub) |
+| Indie Hackers post | Same karma gate as HN viduus |
+| HN regular submit | Pending decision — viduus is shadow-flagged, no other account ready |
+
+**Not done (skipped for today):**
+
+- LinkedIn announcement (5-10 min, warm audience, ~1-3 stars)
+- Personal DMs (10-15 contacts, the GUARANTEED floor — 5-10 stars)
+- X thread (5 tweets — infrastructure not driver with 2 followers)
+- Hashnode cross-post (5 min copy-paste)
+- GitHub Topic / awesome-* submissions
+
+### Monitor schedule
+
+**Tonight / overnight (May 12 → May 13):**
+
+| Channel | What to check |
+|---|---|
+| github.com/mrviduus/textstack/stargazers | New external stars overnight |
+| dev.to article | Reactions count, comments, views (Stats tab on article) |
+| reddit.com/r/selfhosted/{post-id} | Upvotes, comments |
+| x.com/Rexetdeus | Notifications (follow-backs from 21 follows, replies on Theo/Mason) |
+| Inbox `mrviduus@gmail.com` | dang reply about HN unflag |
+
+**Tomorrow morning (May 13):**
+
+- If Hackernoon approved overnight: their newsletter distribution kicks in → potential burst
+- Reddit r/selfhosted: 24h mark = visibility cliff (post moves out of /new into archive)
+- dev.to article: organic Google indexing starts to matter
+
+### Realistic 7-day expectation (revised based on actuals)
+
+| Outcome | Probability |
+|---|---|
+| 0 external stars in 7 days | ~10% (would mean total Wave 2 failure) |
+| 1–5 external stars | ~40% (median outcome — slow accrual from dev.to + Reddit) |
+| 5–15 external stars | ~30% (if Hackernoon approves + Reddit catches some upvotes) |
+| 15–50 external stars | ~15% (if one channel meaningfully lands) |
+| 50+ external stars | ~5% (would require article hitting Google trending, Reddit /hot, or newsletter pickup) |
+
+**Median estimate: 5-10 external stars by May 19.**
+
+### Key learnings — Wave 2
+
+1. **DEV Challenge submission has visibility boost** — challenge tag surfaces article in dedicated feed
+2. **Hackernoon URL-import scrapes page chrome** — should use "Blank Draft With Editor 3.0" + manual markdown paste instead (or accept editor cleanup)
+3. **r/LocalLLaMA has sub-specific karma gate** — like HN, requires patience for proper submission
+4. **dev.to API gives clean markdown** via `/api/articles/{username}/{slug}` — useful for cross-platform repurposing
+5. **X reply game is real infrastructure** — Theo and Mason replies were appropriate, substantive, didn't spam-flag despite low follower count
+6. **DM list still unexecuted** — this remains the single biggest reliable lever for stars but requires user's contact knowledge
+
+---
+
+### Tomorrow (May 13+) — Wave 3 candidates
+
+**High-priority if no traction:**
+- [ ] **Personal DMs** to 10-15 dev contacts — single most controllable channel
+- [ ] **LinkedIn announcement** post (~300 words, link to dev.to)
+- [ ] **Hashnode cross-post** of dev.to article (canonical = dev.to)
+- [ ] **X thread** for build-in-public credit + retargeting material
+
+**Medium-priority:**
+- [ ] Continue HN unflag waiting for dang (24-48h more max)
+- [ ] r/LocalLLaMA karma build (1 thoughtful comment per day, no product link)
+- [ ] r/programming submit (risky, strict mods)
+- [ ] r/devops submit (different angle: production deployment story)
+- [ ] Email pitch to Bytes / JavaScript Weekly / TLDR Tech newsletters
+
+**Low-priority:**
+- [ ] GitHub Topic submissions (awesome-readers, awesome-llm-apps, awesome-selfhosted)
+- [ ] Prepare GitHub Copilot Challenge submission (when launches)
+- [ ] Prepare Google I/O 2026 Writing Challenge submission (when launches)
+
+### Tomorrow (May 12) — Wave 2 distribution of Gemma 4 article
+
+**Sequence (timezone Toronto = ET):**
+
+| Time | Action |
+|---|---|
+| 08:00 | Publish dev.to article from preview |
+| 08:15 | Hackernoon "Import Story" — paste dev.to URL, set canonical, submit for editorial review (24-72h queue) |
+| 08:30 | X announcement thread: main tweet + 3-5 follow-ups with key insights (3GB/30GB RAM, 63k requests, $0.002, p95=20.5ms, e4b→e2b swap, six bugs) |
+| 09:00 | Reddit r/LocalLLaMA submit — Gemma 4 angle (~180K subs, prime audience) |
+| 09:30 | HN submit as REGULAR link (not Show HN) — title from dev.to, URL points to dev.to article. **NOT from viduus** — use older account if available, OR ask friend with HN karma, OR skip HN. |
+| 10:00 | LinkedIn short announcement post linking dev.to |
+| Optional | Cross-post to r/MachineLearning + r/SideProject with different angles |
+
+**Why dev.to article is positioned strongly:**
+1. Confession hook ("shipped features, prod never ran them") — dev community always reads
+2. Concrete numbers in every paragraph (RAM, requests, latency, cost) — HN/dev.to love specifics
+3. Story arc with two model swaps + six bugs — proper journey
+4. TextStack as setting, NOT subject — frames as tech post-mortem, not product pitch
+5. Gemma 4 Challenge submission — dev.to boosts challenge entries
+
+### This week (after Wave 2)
+- [ ] 5-7 more IH comments (1-2 per day, building toward posting privilege)
+- [ ] Personal DM outreach (5-10 dev contacts, highest-conversion channel)
+- [ ] If dang unflags viduus: slow karma build with non-product comments
+- [ ] Submit repo to GitHub Topic lists / awesome-lists (awesome-readers, awesome-llm-apps, awesome-selfhosted)
+- [ ] Cover image for Hackernoon article (styled RAM graph or production logs screenshot — Hackernoon prominently displays it)
+
+### This week
+- [ ] dev.to follow-up article (1.5-2h work, write + publish)
+- [ ] LinkedIn post (30min)
+- [ ] 5-7 more IH comments (1-2 per day, different threads, building karma)
+- [ ] Personal DM outreach (5-10 contacts)
+- [ ] If dang unflags HN: continue slow karma build on viduus, no product links
+
+### Next week+
+- [ ] If IH posting unlocked: submit TextStack as proper IH post
+- [ ] dev.to second article (different angle, e.g., technical deep-dive on Explain feature)
+- [ ] Lobsters (if invite obtainable)
+- [ ] Reddit secondary subs: r/selfhosted, r/opensource (different angles per sub)
+- [ ] Show HN attempt (only if viduus has 20+ karma by then, ideally 50+)
+
+---
+
+## Realistic outcome expectations
+
+| Timeframe | Expected external stars |
+|---|---|
+| End of day 1 (today) | 0–3 |
+| End of week 1 | 5–15 |
+| End of month 1 | 20–50 (if consistent activity) |
+| End of month 3 | 100+ (if dev.to article ranks in Google + 1-2 Show HN attempts land) |
+
+Star count is a lagging signal. Better leading indicators:
+- Repo clones (visible in `Insights → Traffic`)
+- textstack.app analytics (if instrumented)
+- Replies/comments on our posts (engagement)
+- Followers gained on @Rexetdeus
+
+---
+
+## Key learnings logged
+
+1. **HN karma=1 + product link + multiple-comments-same-hour = auto-flag.** Should have waited 6-12 hours between comments and avoided product link in first one.
+2. **IH gates posting** through manual mod review — different from HN, requires "authentic contribution pattern" demonstrated through comments.
+3. **Reddit r/SideProject is the most accessible big channel** — designed for self-promo, no significant gating beyond avoiding spam patterns.
+4. **X reach for new pinned post** is small without paid promotion or established following — ~50 views per post baseline for ~2-follower account.
+5. **GIF/visual quality matters** — cropping out browser chrome + dock made the asset look professional vs amateur recording.
+6. **The DDIA hook is strong** — multiple platforms responded to it positively; keep this as primary narrative anchor.
+7. **Paid-partnership / sponsored posts must be filtered out before drafting.** On 2026-05-21 a candidate (@NikkiSiapno) passed the search-preview check, but the full post carried a "Paid partnership" label + #Ad — pure promo by definition, off-strategy to reply under. Always open the full post and check for an #Ad / "Paid partnership" marker before drafting; a search-result excerpt can hide it.
+8. **Drafting without posting produces zero growth.** Followers tracked 2 → 11 (after the May 14–15 posting burst of 8 replies) → decayed to 6 across May 18–21 when drafts were generated but never posted. The reply-game only compounds if replies actually go out; a drafts file alone is wasted effort.
diff --git a/docs/marketing/ih-launch-draft.md b/docs/marketing/ih-launch-draft.md
new file mode 100644
index 00000000..e527ae62
--- /dev/null
+++ b/docs/marketing/ih-launch-draft.md
@@ -0,0 +1,151 @@
+# Indie Hackers — First Starting Up Post
+
+**Status**: FINAL DRAFT v2 — optimized for max reach (sharp hook, scannable, concrete)
+**Channel**: Starting Up section on indiehackers.com
+**Goal**: первый пост от @textstack, founder story, drive engagement + visibility
+**Tone**: honest, builder voice, no marketing, no fabricated numbers
+
+---
+
+## Pre-publish checklist
+
+- [ ] Прочитать вслух — звучит как ты, не как маркетолог?
+- [ ] Цифры верифицированы: 25 users / 9 returning / 32m 36s avg / 44 GSC clicks
+- [ ] No fabricated данные (нет SRS retention %, нет "users upload 2-3 books")
+- [ ] Опубликовать Tue/Wed/Thu 8-11am EST
+- [ ] Готов первые 2 часа отвечать на комменты
+
+---
+
+## Title (final)
+
+# I built software to read one book.
+
+**Backup options** (если первый не нравится):
+- I gave up on DDIA three times. So I built a reader to finish it.
+- Six months of code to finish one technical book
+
+**Recommendation**: первый — самый сильный hook на ленте IH. Curiosity-driven, ничего не требует знать заранее. В первых 2 строчках IH preview виден весь интригующий setup.
+
+---
+
+## Body (final, ~390 words)
+
+I gave up on *Designing Data-Intensive Applications* three times.
+
+The third time, I built software to finish it. Six months later, that software is TextStack — open source, AGPL-3.0, free at textstack.app.
+
+---
+
+**The friction**
+
+The problem wasn't the math. It was vocabulary.
+
+Page 256 of DDIA uses "phantom" as a database isolation anomaly. The dictionary tells me it's a ghost. Google tells me it's a Rolls-Royce model. Kindle's Word Wise — same.
+
+Every chapter has 5-10 words like that. Each lookup breaks the thread. I gave up on chapter 7 three times.
+
+---
+
+**What I built**
+
+A reader that knows what book it's reading.
+
+Tap any word, get a 2-3 sentence explanation in the book's domain. "Phantom" in DDIA returns the database meaning, not the ghost.
+
+The rest:
+
+- Upload EPUB/PDF/FB2 — your own books
+- Vocabulary SRS with 5 stages (Recognition → Recall → Context → Mastered)
+- Edge TTS audio, no API key needed
+- Translation via OpenAI, dictionary, full-text search
+
+Stack: .NET 10 + PostgreSQL backend, React + React Native frontend. Self-host with `docker compose up`, or try at textstack.app without signup.
+
+---
+
+**Three weeks of clean data**
+
+- **25 unique users.** 19 new, 9 returning.
+- **32 minutes** average engagement time per user.
+- **8.2 sessions** per active user.
+- **44 Google clicks** in 3 months (broader trajectory).
+
+Most of those 25 are people I told directly. The 9 organic strangers are scattered: US, Ireland, Pakistan, Colombia. Tiny audience, but the engagement says the ones who find it actually read.
+
+The hard part: my audience — non-native English speakers reading technical books in English — is real but globally distributed. They're not concentrated in one subreddit or one country.
+
+---
+
+**Two questions**
+
+1. If you've ever quit a technical book — what was the friction? Was it vocabulary like me, or something else I'm missing?
+
+2. How did you find your first 100 real users when your audience isn't in one place? Open to anything that worked.
+
+---
+
+github.com/mrviduus/textstack — happy to dig into any technical decisions in the comments.
+
+---
+
+## Why this version works better than v1
+
+| Element | v1 | v2 |
+|---------|----|----|
+| Opening | "I'm Vasyl. For the last six months..." (warmup) | "I gave up on DDIA three times." (hook) |
+| Friction example | "5-10 terms with domain-specific meanings" (abstract) | "Page 256, 'phantom'..." (concrete) |
+| Metrics format | Bulleted list mid-paragraph | Pull-quote block with bold numbers |
+| Paragraph length | 4-6 lines (wall on mobile) | 1-3 lines (scannable) |
+| Subheadings | None | "The friction" / "What I built" / "Three weeks of data" / "Two questions" |
+| Closing | List of features + questions | One line + GitHub link |
+| Word count | 430 | 390 |
+
+The story arc is the same — failure → root cause → built solution → honest metrics → ask community. But every paragraph fights for the reader's next 5 seconds of attention.
+
+---
+
+## Длина и формат
+
+~390 слов. Это оптимальная длина для IH Starting Up posts — короче 250 выглядит лениво, длиннее 600 не дочитывают.
+
+Subheadings (bold, single phrase) разбивают пост на 4 ясных секции — каждую видно на screen view даже не скроллируя.
+
+Pull-quote блок с метриками — самая важная часть для scanners. Кто пробегает глазами — видит цифры. Кто читает — видит контекст.
+
+---
+
+## Когда публиковать
+
+- **Best**: Tuesday, Wednesday, Thursday 8-11am EST
+- Для Eastern Canada (твой часовой пояс) это 8-11am локально
+- **Avoid**: weekends (посты теряются), monday morning (founders ещё разбирают почту), late afternoon EST (поздний US, спящая Европа, Азия спит)
+- **Avoid**: дни больших announcements (большие YC демо, Stripe Press launch и т.п.)
+
+---
+
+## Что делать первые 2 часа после публикации
+
+1. Каждые 10-15 минут проверять комменты — отвечать substantive в течение 15-30 минут
+2. На каждый комментарий отвечать с **вопросом обратно** ("What about X in your case?") — это удваивает депость threads
+3. Не "Thanks for the comment!" — это спам и алгоритм это видит
+4. Если кто-то критикует — спроси follow-up, не защищайся. "What would have made it work for you?" гасит конфликт.
+5. Не упоминать в комментариях Twitter handle или другие projects — выглядит как self-promo carousel
+
+## После 2-3 часов когда уже есть engagement
+
+- Cross-post в Twitter с одной строкой: "Wrote about why I built TextStack on Indie Hackers — would love feedback" + link
+- НЕ запрашивать апвоты — IH модерация banит за это
+- НЕ постить в нескольких subreddit-ах с тем же текстом — выглядит как dropping
+
+## Через 24-48 часов
+
+- **Engagement хороший** (10+ comments, 20+ upvotes) — это валидация, через 3-4 недели можно повторно постить с другим angle (milestone post через месяц)
+- **Тишина** — это тоже data. Возможно timing был плохой или title не зацепил. Не паника, не удалять. Через 4-6 недель попробовать снова с другим hook.
+
+## Чего не делать после поста
+
+- Не постить второй пост в IH в течение недели — выглядит как спам
+- Не отвечать на критику оборонительно — "Actually if you read more carefully..." убивает goodwill
+- Не давать промокоды/discount — продукт бесплатный, не надо
+- Не упоминать "btw also check out my @..." в комментариях — плохая форма на IH
diff --git a/docs/marketing/ih-warmup-comments.md b/docs/marketing/ih-warmup-comments.md
new file mode 100644
index 00000000..b98baf8b
--- /dev/null
+++ b/docs/marketing/ih-warmup-comments.md
@@ -0,0 +1,172 @@
+# IH Warm-up Comments
+
+Цель: получить posting access на IH через substantive comments. Модераторы вручную грантят его аккаунтам которые показывают authentic contribution. Срок обычно 5-14 дней при стабильной активности.
+
+## Правила безопасности (важно — чтобы не забанили)
+
+1. **Никаких ссылок на textstack.app** в комментах. Вообще. Никогда.
+2. **TextStack упоминать минимально**: только если прямо по теме поста, и только мимоходом без call-to-action.
+3. **Никаких "Great post!" / "Thanks for sharing"** — модераторы это видят как spam-индикатор и грантят доступ медленнее или не грантят вообще.
+4. **80-150 слов на коммент** — достаточно substantial, но не назойливо.
+5. **1-2 коммента в день максимум** — НЕ 5 за один присест в один вечер. Это выглядит как фарминг.
+6. **Только на посты которые тебе реально интересны** — это видно по тону.
+7. **Не давать совет с высоты опыта если у тебя его нет** — на IH быстро ловят posers.
+8. **Не упоминать в комменте свой Twitter, GitHub, ничего своего** — после grant можешь добавить, сейчас фокус на value to OP.
+
+## Cadence (рекомендую)
+
+- **Понедельник (сегодня)**: 1 коммент. Самый сильный — post #1.
+- **Вторник**: 1 коммент. Post #2 или #3.
+- **Среда**: 1-2 коммента. Из оставшихся.
+- **Четверг**: 1 коммент.
+- **Пятница**: 0-1 коммент.
+- К выходным проверь grant — если есть, публикуй пост во вторник 26-го мая.
+
+---
+
+## Comment #1 — READY TO POST
+
+**Post**: "The most embarrassing realization I had this week: Our startup was completely invisible to Google." by Russ & Ali (fixRAgent)
+
+**URL**: https://www.indiehackers.com/post/the-most-embarrassing-realization-i-had-this-week-our-startup-was-completely-invisible-to-google-c878bf461c
+
+**Context**: Non-technical founders launched fixRAgent, hit PH #44, ran Meta ads, then realized they were invisible on Google. They ask: "What is the most obvious tech-world standard practice that completely blindsided you when you first started?"
+
+**Why this is perfect for you**: ты прямо сейчас живёшь следующий уровень их урока — у тебя GSC был с дня 1, и всё равно 44 клика за 3 месяца. У тебя есть real insight, не теория.
+
+**Draft (copy-paste ready)**:
+
+```
+The follow-up rabbit hole gets weirder when you DO have GSC set up from day 1. I did — submitted sitemap, schema markup, the whole technical checklist before launch. Three months in: 44 clicks, average position 59 on most queries.
+
+The lesson that blindsided me wasn't "submit your sitemap", it was: indexed ≠ ranked. Getting Google to know you exist is the easy part. Getting Google to put you above 60 other sites that figured it out earlier is the actual work, and there's no clean checklist — backlinks, content depth, and time, where the first two are slow and the third is just slow.
+
+For non-technical founders specifically: build the muscle of reading the Performance report weekly. Impressions growing without clicks growing means your titles and descriptions are weak, which is fixable in an hour. That's the next layer past "just submit the sitemap".
+```
+
+~150 слов. Substantive, honest, не упоминает TextStack, дает прямой actionable совет для OP.
+
+---
+
+## Comment #2 — TEMPLATE (need to read post first)
+
+**Post**: "I built a URL indexing SaaS in 40 days — here's the honest story" by @alex80
+
+**Why relevant**: Прямо на тему indexing. Если он строит indexing-SaaS, у него есть мнение про что в Google Search Console сейчас сломано/работает. Можешь поделиться своим опытом с indexing patterns.
+
+**Прочитать первое**, потом написать коммент на основе:
+
+- Какие конкретные insights он раскрывает в посте?
+- Есть ли в его подходе что-то с чем ты не согласен из собственного опыта?
+- Можешь ли добавить data point из своего опыта (например, что у тебя только 9% URL индексировано из ~3.6K crawled)?
+
+**Black-out draft** (после чтения поста подставь специфику):
+
+```
+Honest write-ups like this are useful — most indexing-saas content I've seen is either fearmongering ("Google hates you!") or magic ("just call this endpoint!").
+
+Specific data point from my side: in 3 months I have 391 URLs in sitemap, 327 indexed, but Google has discovered ~3.6K URLs total via internal links. Of the non-sitemap URLs about 2K are intentionally noindex (chapter-level content I don't want competing with public-domain sources), the rest sit in "Crawled - currently not indexed" purgatory.
+
+What I learned the hard way: sitemap submission is necessary but probably 20% of the actual indexing work. The other 80% is making each page substantial enough that Google decides it's worth keeping. Curious if your tool surfaces that or sticks to the technical "is this discoverable" layer — they feel like different problems to me.
+```
+
+~150 слов. Делится конкретными числами без preaching, заканчивается вопросом который дает OP повод ответить.
+
+---
+
+## Comment #3 — TEMPLATE (need to read post first)
+
+**Post**: "I made a mistake every first-time founder makes — I built first, validated later. Here's what I'd do differently." by @SuhailQureshi
+
+**Why relevant**: Универсальная founder story. У тебя похожий путь — построил TextStack, теперь ищешь свою аудиторию.
+
+**Прочитать первое**, потом написать на основе:
+
+- Какие конкретные вещи он жалеет?
+- Совпадают ли с твоим опытом? Или были разные?
+- Есть ли что-то ценное что ты можешь добавить о своём пути?
+
+**Skeleton draft** (после прочтения адаптируй):
+
+```
+I'm in a similar arc but at the "still figuring it out" stage. Built [TextStack] for myself first — I had a specific frustration with reading technical books as a non-native English speaker, and the existing tools (Kindle Word Wise, dictionaries) didn't fit. The "build first" part worked because I was the user.
+
+Where I'm hitting your "validate later" wall: I have ~25 active users in the last 3 weeks, 9 returning, average session 32 minutes. The engagement says the product works. The hard part is finding the next 100 — the audience is real but globally distributed, no single subreddit or community.
+
+If I were starting over, I'd ask the "build vs. discover" question differently: not "should I validate?" (yes, always) but "validate WHAT specifically?". For me, "is there a vocabulary problem with technical books?" was the obvious thing to validate. "How do I find non-native English readers globally?" is what I should have validated, and didn't.
+```
+
+~165 слов. Здесь TextStack упомянут один раз и только для контекста — это нормально и даже честно. НЕТ ссылок, НЕТ "check it out".
+
+---
+
+## Comment #4 — TEMPLATE (need to read post first)
+
+**Post**: "I used to think onboarding was unnecessary. I was wrong." by @showesome
+
+**Why relevant**: У TextStack reader с vocabulary SRS — сложная фича которая требует объяснения. У тебя есть real onboarding опыт.
+
+**Прочитать первое**, потом адаптировать:
+
+- Какие конкретные onboarding ошибки он описывает?
+- Что у тебя в TextStack onboarding'е? Есть ли паттерны которые он не упомянул?
+
+**Skeleton**:
+
+```
+The thing that flipped for me on onboarding: it's not "show the user what's possible", it's "give them one specific win on day 1". For my reading app, the day-1 win is "tap a word, see a definition aware of the book's domain" — not "look at all these features".
+
+The temptation when you ship something with multiple features (SRS, dictionary, translation, TTS) is to walk the user through each one. I tried that. Users churned. Now the onboarding shows one tap-explain interaction on a sample page, then gets out of the way. The other features get discovered organically when the user is ready.
+
+What I'd add to your post: track the activation event specifically. For me it was "user taps and reads an explanation". Until that event fires for a new user, every feature beyond that is noise.
+```
+
+~155 слов. Конкретный пример из своего опыта, actionable совет (track activation event).
+
+---
+
+## Comment #5 — TEMPLATE (need to read post first)
+
+**Post**: "I built a cron job monitor. Now I'm trying to figure out who actually loses sleep over this problem." by @KrasimirP
+
+**Why relevant**: У тебя такая же проблема — продукт работает, но аудитория distributed and hard to reach.
+
+**Прочитать первое**, потом:
+
+```
+The "who actually loses sleep" framing is good but here's a trap I fell into with my own niche product: the people who lose sleep are often NOT the people who buy. For cron monitoring, the engineer who got paged at 3am is the one losing sleep, but the budget owner who pays for monitoring is their manager or platform team lead.
+
+I'd separate two questions:
+1. Who has the pain hard enough to seek a tool? (your sleep-losers)
+2. Who has the authority to commit to a tool? (your buyers — different person, sometimes the same)
+
+For my reading app I conflated these for months — built features for the "I want to actually finish this book" pain (the user), then wondered why nobody was telling friends. Turns out the person you tell about a reading app isn't the person who suffered through the friction; you forget the friction once you've finished.
+
+Curious if cron monitoring has this gap or if pain-haver = buyer in your space.
+```
+
+~175 слов — чуть длиннее. Если сократить — убери последнюю строчку про "Curious if".
+
+---
+
+## Self-check перед каждым коммент
+
+Перед нажатием Submit на любом коммент, ответь "да" на ВСЕ:
+
+- [ ] Я отвечаю конкретно на то что сказал OP, а не пишу общий совет?
+- [ ] Я даю value автору поста, а не пытаюсь продать себя?
+- [ ] Нет ссылок на textstack.app или другие мои проекты?
+- [ ] TextStack упомянут максимум один раз и только для контекста, без CTA?
+- [ ] Это звучит как мой голос, а не как ChatGPT-сгенерированный коммент?
+- [ ] Это не воскресенье/праздник (когда комменты теряются)?
+- [ ] Это мой 1-й или 2-й коммент за сегодня (не 5-й)?
+
+Если хотя бы один "нет" — не пости, переработай.
+
+---
+
+## После того как получишь posting grant
+
+1. Опубликуй основной пост (draft в [ih-launch-draft.md](./ih-launch-draft.md))
+2. Twitter cross-post через 2-3 часа после
+3. Продолжай делать substantive комменты на posts которые тебе интересны — 2-3 в неделю это хороший cadence для долгосрочного presence на IH
diff --git a/docs/marketing/linkedin-routine/README.md b/docs/marketing/linkedin-routine/README.md
new file mode 100644
index 00000000..cf244a74
--- /dev/null
+++ b/docs/marketing/linkedin-routine/README.md
@@ -0,0 +1,129 @@
+# LinkedIn comment-game playbook
+
+Companion to `docs/marketing/x-routine/`. Runs **Mon/Wed/Fri at 10:00 AM Toronto** via the `daily-linkedin-comment-game` scheduled task. Goal: build a niche professional audience (AI engineers + .NET / dev community) through substantive comments on others' posts — same engagement philosophy as the X reply-game, but tuned for LinkedIn's different mechanics.
+
+## Why LinkedIn
+
+- LinkedIn's algorithm rewards **dwell time on comments** more than reactions; a 3-sentence substantive comment outperforms 10 likes.
+- The audience is older, more decision-shaped (technical leads, hiring managers, founders), conversion to actual contributors / TextStack users is much higher per impression than X.
+- Reply windows are longer — a comment posted 24h after the parent post still gets visibility, unlike X where the 1-3h window is hard.
+
+## Tone & format vs. X
+
+| | X reply | LinkedIn comment |
+|---|---|---|
+| Length | 100-280 chars | 200-600 chars (2-4 sentences) |
+| Hook | Optional | First sentence must stand alone — LinkedIn collapses by default |
+| Emoji | Max 1, rare | None unless matching parent's tone exactly |
+| Hashtags | Never | Never in comments (only in own posts) |
+| Sign-off | None | None (no "Cheers, Vasyl" — looks AI-generated) |
+| Persona | Peer-to-peer, terse | Peer-to-peer, slightly more formal but still direct |
+
+## Target tribe — priority order
+
+These are **names**, not URLs. The scheduled-task run will need to search LinkedIn for each. The pool is intentionally larger than the X list because LinkedIn surfaces a smaller subset of any given person's posts per visit.
+
+**Tier A — .NET / Microsoft ecosystem (highest topical overlap with TextStack stack):**
+- Scott Hanselman (Microsoft, .NET evangelism)
+- David Fowler (.NET architect)
+- Damian Edwards (.NET PM)
+- Khalid Abuhakmeh (JetBrains, .NET advocacy)
+- Maarten Balliauw (.NET, Azure)
+- Jeff Fritz (Microsoft .NET community)
+- Andrew Lock (.NET author)
+- Steve Smith (Ardalis, .NET architecture)
+
+**Tier B — AI engineering / local LLM ecosystem:**
+- Simon Willison (Datasette, llm CLI)
+- swyx (Latent Space)
+- Andrej Karpathy (sporadic on LinkedIn but high-value)
+- Mitchell Hashimoto (HashiCorp founder, recent solo AI work)
+- Harrison Chase (LangChain)
+- Aravind Srinivas (Perplexity)
+- Logan Kilpatrick (Google AI / DeepMind)
+- Eduards Sizovs (sizovs.net — dev architecture content, strong LinkedIn)
+
+**Tier C — Indie / build-in-public (highest reply-back rate):**
+- Arvid Kahl (FeedbackPanda, Podscan)
+- Pieter Levels (post sometimes on LinkedIn)
+- Marc Lou
+- Daniel Vassallo
+- Sahil Lavingia
+
+**Tier D — Recruiter / hiring-adjacent (low engagement priority but parallel benefit):**
+- Gergely Orosz (Pragmatic Engineer)
+- Ryan Peterman (FAANG-focused content)
+
+## What to comment on
+
+- A post with a **claim** (architecture take, performance number, framework opinion) — counter or extend with a data point.
+- A post with a **question** asked sincerely — answer it concretely.
+- A post sharing a **debugging story** — share a parallel war-story (this is where TextStack production experience naturally fits).
+- A post about **AI / local LLM tradeoffs** — peer-to-peer technical perspective.
+
+## What to skip
+
+- Pure promotion ("excited to announce" / launch posts) — engagement value is low; LinkedIn already amplifies these.
+- Long-form opinion threads with 200+ comments already — your comment drowns.
+- Posts older than 48 hours — LinkedIn algorithm has decayed by then.
+- Political / current-events posts — same tribe constraints as X.
+- "Inspirational" / motivational posts — wrong tribe.
+
+## TextStack reference rules — LinkedIn ≠ X
+
+LinkedIn is **personal brand only**. Do not name TextStack, do not cite TextStack production numbers, do not use "we shipped X" framing that ties back to the product. This is the **opposite** of the X reply-game (where 1 reference per session is allowed and working).
+
+- **Zero** TextStack mentions per session — no exceptions, even when it would topically fit.
+- **Never include** github.com/mrviduus/textstack or textstack.app URLs.
+- If a comment would only work with a TextStack reference, **drop the comment entirely** rather than rewriting around it. Speak generically about CPU-only deployment, local LLM tradeoffs, etc., without naming the product.
+- Speak as a senior AI engineer with opinions and war stories — authority + perspective, not founder-led product PR. Recruiters and hiring managers respond to the former and tune out the latter.
+
+## Constraints (never violate)
+
+- **Never post a comment autonomously.** Each comment requires explicit per-message approval from the user. Scheduled task drafts; user approves and posts (or asks Claude to post once approved).
+- **Never send connection requests autonomously.**
+- **Never react / like autonomously** — that changes profile-side state.
+- **Never engage with political, religious, geopolitical, or controversy content.**
+
+## Output format
+
+Save drafts to `docs/marketing/linkedin-routine/YYYY-MM-DD.md` in this format:
+
+```markdown
+# LinkedIn comment-game drafts — [DATE]
+
+Generated by daily-linkedin-comment-game scheduled task.
+N candidates selected. Pending user review and per-comment approval before posting.
+
+---
+
+## Candidate 1 — [Name] ([Title at Company])
+
+**Source post:** [LinkedIn URL]
+**Posted:** [N hours ago]
+**Excerpt:** "..." (2-3 lines of the original)
+
+**Draft comment:**
+> [comment text — 200-600 chars]
+
+**Why this adds value:** [one line of reasoning]
+
+---
+
+[continue for 2-5 candidates]
+```
+
+## Cumulative tracking
+
+Append a one-line summary to `docs/marketing/campaign-tracker.md` under a new section `## LinkedIn comment-game log` after each session — mirror the X routine log format.
+
+## Calibration
+
+- Weekly: 1-3 new connections expected by week 4 (lower volume than X follows but higher per-touch conversion)
+- Monthly: 10-30 new connections by month 1
+- A comment has succeeded if it (a) gets a reply from the original poster, or (b) prompts a connection request from another commenter
+- A session has succeeded if 3+ candidates were drafted with substantive value
+
+## Notes for the runtime
+
+The current LinkedIn URL for the @Rexetdeus / TextStack account is **unverified** — the scheduled task should first navigate to `https://www.linkedin.com/feed/` and confirm the logged-in user matches Vasyl Vdovychenko before scanning. If the session isn't logged in, write a one-line markdown file at `docs/marketing/linkedin-routine/YYYY-MM-DD.md` saying "LinkedIn session not authenticated — skipped" and exit cleanly.
diff --git a/docs/marketing/x-routine/2026-05-12.md b/docs/marketing/x-routine/2026-05-12.md
new file mode 100644
index 00000000..d778dfcd
--- /dev/null
+++ b/docs/marketing/x-routine/2026-05-12.md
@@ -0,0 +1,55 @@
+# X reply-game drafts — 2026-05-12
+
+Generated by daily-x-reply-game routine (first run, manual trigger).
+Session notes: Following feed mostly noise today (Ferarri Prime amplifies growth-hack reposts; afternoon EDT = dev quiet hours). Found best candidates by going directly to @simonw profile after search returned mostly stale content.
+
+**2 candidates** selected. Pending user review and per-message approval before posting.
+
+---
+
+## Candidate 1 — @simonw (HIGH PRIORITY) ✅ POSTED
+
+**Source post:** https://x.com/simonw — pinned post from 22h ago
+**Author:** Simon Willison (174.5K followers — Tier B, we follow)
+**Post excerpt:**
+> "My Mac had less available memory than I expected, turned out the 'claude' Claude Code processes on this machine (running in various terminal windows) were consuming ~30GB on their own!"
+
+**Draft reply:**
+> Same number 😂 — we run gemma4:e2b production on a 30GB CPU VPS (no GPU, no concurrency limit). Claude Code is squatting that exact limit on dev laptops. Wondering if there's a 'lightweight context' mode that drops to <5GB when idle, or is reservation always full?
+
+**Why this adds value:**
+Concrete production-memory anecdote that mirrors Simon's exact 30GB number from the opposite direction (his Mac vs our prod VPS). Asks a real question Simon might actually know the answer to (Claude Code memory management). Natural TextStack production reference without link.
+
+**Day quota used:** 1/1 TextStack production reference for today.
+
+---
+
+## Candidate 2 — @simonw (MEDIUM PRIORITY)
+
+**Source post:** https://x.com/simonw — 20h ago
+**Author:** Simon Willison (same)
+**Post excerpt:**
+> "Wrote about today's GitLab restructuring / 'workforce reduction' announcement, and ended up digging around in version control for both the GitLab and the 37signals public employee handbooks to help illustrate my thoughts"
+> (with link to simonwillison.net/GitLab Act 2)
+
+**Draft reply:**
+> Curious — when you mine these public handbooks via version control, are you scripting the diff-extraction or doing it manually? I'd kill for a tool that surfaces 'what changed in policy X over the last N revisions' without me having to bisect git blame by hand.
+
+**Why this adds value:**
+Specific question about Simon's workflow (he's known for tool-building, will engage). Implies a real research need (handbook-diff tool) that's adjacent to his expertise. No TextStack mention — pure community engagement.
+
+---
+
+## Continued conversations
+
+None today — checked x.com/Rexetdeus/with_replies, no responses on prior replies to @theo or @masondrxy.
+
+## Notifications
+
+No new external follows or notable likes. Same self-engagement from @gl1tchmary alt account, same Paul Chen promo bot reply.
+
+## Routine notes
+
+- Feed quality issue: Ferarri Prime + a few accounts are reposting growth-hack "follow for follow" spam, flooding Following tab. Consider muting @callmeoscar_1, @CelebrityPuls_1, and similar accounts that retweet through Ferarri Prime to clean up the feed.
+- Best content found by going DIRECT to @simonw profile rather than feed/search. Future runs: navigate to top 3-5 target profiles individually if Following feed is noisy.
+- Afternoon EDT (14:50) is dev-quiet hours. Optimal post time for engagement is 8-10am ET or 6-9pm ET. Tomorrow's scheduled 9:04am ET run hits the morning peak.
diff --git a/docs/marketing/x-routine/2026-05-13.md b/docs/marketing/x-routine/2026-05-13.md
new file mode 100644
index 00000000..de94e7eb
--- /dev/null
+++ b/docs/marketing/x-routine/2026-05-13.md
@@ -0,0 +1,92 @@
+# X reply-game drafts — 2026-05-13 (Wednesday)
+
+Generated by `daily-x-reply-game` scheduled task.
+4 candidates selected (3 fresh + 1 continued). Pending user review and per-message approval before posting.
+
+**Session note:** Following feed was again dominated by mutual-follow engagement-bait (Web3_Jarin, Gooddlovee, Sindirella, CoinMarketCap). No on-topic candidates surfaced there. Falling back to live search + target-tribe profile scan worked — pulled two strong on-topic posts from `f=live` search, one from the official @dotnet account.
+
+---
+
+## Candidate 1 — @im_yeyito (STRONG / TOP PICK)
+
+**Source post:** https://x.com/im_yeyito/status/2054547865867845997
+**Posted:** ~7m before scan (still in peak visibility window)
+**Excerpt:**
+> "llama.cpp getting a real eval path would be huge. local inference has plenty of vibes and anecdote already; comparable community evals are how the weird builds stop being impossible to reason about."
+
+**Draft reply:**
+> Hard agree. Ran 63k requests through gemma4:e2b on a single-CPU prod VPS — p95 20.5ms, 100% success — and posting those numbers still felt like vibes-into-the-void. Nobody publishes comparable runs. A standard local-inference eval harness would change the whole conversation.
+
+**Why this adds value:** Directly answers the "vibes vs comparable data" complaint with a concrete data point from our own deployment. This is the one reply per session that's allowed to drop TextStack production numbers, and this is the perfect spot for it (the parent post is literally asking for that kind of data). Ends with implicit invitation to keep the methodology conversation going.
+
+---
+
+## Candidate 2 — @VladimirVivien
+
+**Source post:** https://x.com/VladimirVivien/status/2054542251368263751
+**Posted:** ~27m before scan
+**Excerpt:**
+> "Generating source code with Gemma 4 — Gemma 4 2B on CPU, LiteRT-LM runtime, Example in litertlm-go"
+
+**Draft reply:**
+> Curious how LiteRT-LM compares to llama.cpp/ollama for CPU inference under concurrent load. We ended up downsizing from gemma4:e4b to e2b on a single-CPU VPS once load hit — couldn't keep TTFB under 2s otherwise. What's your throughput look like on 2B?
+
+**Why this adds value:** Asks a specific runtime-comparison question framed around our actual e4b → e2b migration. Doesn't mention TextStack by name (no production-numbers reuse). Implicit invitation through the throughput question. Author is a Go/CPU-inference peer — high chance of a real reply.
+
+---
+
+## Candidate 3 — @dotnet (Microsoft official)
+
+**Source post:** https://x.com/dotnet/status/2054548484145946675
+**Posted:** ~5m before scan
+**Excerpt:**
+> "Agentic AI is growing up. Microsoft Agent Framework 1.0 brings stable APIs, A2A cross-runtime messaging, MCP tool discovery, graph-based workflows, and a DevUI debugger for real-time agent tracing. #dotNET + Python parity finally lands."
+
+**Draft reply:**
+> MCP tool discovery in-framework is the missing piece — wiring it by hand in ASP.NET has been the friction point. Is there a sample of A2A actually bridging a .NET agent to a Python one, or is parity mostly API-shape parity for now?
+
+**Why this adds value:** Substantive technical question on a megaphone account (replies on @dotnet posts get high incidental views from the .NET crowd). Frames us as a real ASP.NET builder using MCP. Question is specific enough that a sample link or a "wait for v1.1" answer both move the convo forward.
+
+**Risk:** Megaphone reply threads often get buried. Mitigation: post early before the thread fills up.
+
+---
+
+## Continued conversations
+
+### @PaulChen088 — Synthadoc / Claude Code CLI Provider
+
+**Original thread:** https://x.com/PaulChen088/status/2053865235941826783 (replying to @Rexetdeus + @buildinpublic)
+**Posted:** May 11 (2 days old — outside ideal window, but polite follow-up still worthwhile)
+**Their message:**
+> "It integrates with Claude code and Open code installations using CLI Provider. See this blog for details. [dev.to/synthadoc-your-coding-tool-is-now-your-wiki-brain]"
+
+**Draft reply:**
+> Just opened the post — the CLI Provider angle is clever. Are you persisting the doc index between Claude Code sessions, or rebuilding per-invocation? The "re-derive context every run" tax is the part I haven't seen anyone solve elegantly.
+
+**Why this adds value:** Specific technical question about session persistence — moves us from "thanks" territory to actual peer engagement on his design choices.
+
+---
+
+## New follow-backs
+
+None notable in last 48h. Only inbound activity was from @gl1tchmary (user's own alt — per task constraints, never engage). No external dev/indie/AI accounts in the >1000-follower range followed back.
+
+---
+
+## Candidates considered but rejected
+
+- **@SnowCrashLabs** (6m, "CVE-2026-7482 turned 300K Ollama instances into cross-tenant memory leaks") — sensational format + suspicious CVE number; could be misinformation. Skip per "avoid security/controversy noise" principle. If real, will surface on HN.
+- **@theo** (6h, "Is HTML the new Markdown?") — video reply requires watching to engage substantively; outside the time budget for this session.
+- **@simonw** (May 11, claude processes / 30GB Mac memory) — already engaged in yesterday's session.
+- **@karpathy** (May 11, "structure your response as HTML") — 2 days old, window closed.
+- **@swyx** (11h, "increasing levels of autonomy: /skill, /plan, /goal") — interesting but slightly stale; defer to a fresher post.
+- **@tdinh_me** (4h, referral-link self-congrats) — promotional, not substantive.
+
+## Suggestions for target list
+
+- Add **@VladimirVivien** to peer-CPU-inference watchlist if today's engagement lands — Go + on-device LLM is a tight overlap with our prod stack.
+
+## Calibration notes
+
+- Following-feed signal quality remains low; live search + tribe profile scan continues to be the only viable path. Suggest the user unfollow some of the engagement-bait accounts to clean up the Following feed for future sessions.
+- Today's @im_yeyito candidate is the strongest fit since the routine started — direct ask for the exact data we have publicly written about. Worth posting first.
diff --git a/docs/marketing/x-routine/2026-05-14.md b/docs/marketing/x-routine/2026-05-14.md
new file mode 100644
index 00000000..3beaf43c
--- /dev/null
+++ b/docs/marketing/x-routine/2026-05-14.md
@@ -0,0 +1,114 @@
+# X reply-game drafts — 2026-05-14 (Thursday)
+
+Generated by `daily-x-reply-game` scheduled task.
+4 candidates selected (3 fresh + 1 continued). Pending user review and per-message approval before posting.
+
+**Session note:** Following feed is *still* dominated by mutual-follow engagement-bait (Sindirella, Ferarri Prime, "Say Hi for 550+ followers" templates) — third session in a row. Live search on `"local LLM" OR gemma OR ollama` and `"building in public"` again pulled the real candidates. Worth a follow/unfollow pass on the Following list this weekend.
+
+**Account state:** 11 followers (up from 2 at baseline 3 days ago — +9 in 3 days), 104 following, 28 posts. Bio + DDIA hook in place.
+
+---
+
+## Candidate 1 — @MozillaAI (STRONG / TOP PICK — TextStack mention)
+
+**Source post:** https://x.com/MozillaAI/status/2054909949583450192
+**Posted:** ~5 minutes before scan (peak visibility window)
+**Excerpt:**
+> "A 4B model just read all of Alice in Wonderland offline on a MacBook. We used Gemma 4 with llamafile to test long-context reasoning, memory, and state tracking without the cloud."
+
+**Draft reply:**
+> Curious where the 4B started to wobble on state tracking — we run gemma4:e2b in prod on a CPU VPS for short-form generation and it holds up, but the long-context CPU story still feels brittle. Does llamafile change that vs plain Ollama, or is it mostly packaging?
+
+**Why this adds value:** Asks a specific technical question about a real distinction (llamafile vs Ollama) on a megaphone account (mozilla.ai, 845 followers but Mozilla-branded reach). This is the one allowed TextStack-production-numbers mention of the day — it earns its place here because the parent post is *literally* about Gemma 4 on consumer hardware, which is exactly what we run in prod. High chance of a real reply from mozilla.ai's social team or someone on the llamafile team.
+
+---
+
+## Candidate 2 — @TheWordWeaver_ (peer dev support, high follow-back probability)
+
+**Source post:** https://x.com/TheWordWeaver_/status/2054911084801282142
+**Posted:** ~3 minutes before scan (fresh, peak window)
+**Excerpt:**
+> "Day 3 of building in public 🙃 y'all, I having my ass handed out to me 🤧 9hrs+ now and counting, I been debugging. so here's the issue; I am trying to move Susu build environment from Replit to GitHub+ Vercel so it can get permanent hosting. that is where my woe began"
+>
+> [Screenshot shows Vercel build failing with `framer-motion` module resolution error]
+
+**Draft reply:**
+> That looks like a framer-motion ESM import issue — Replit forgives loose CJS/ESM, Vercel doesn't. Pin framer-motion to a known-good version and make sure no `import` is missing a file extension (`.tsx` in your `src/components/...` lines). Cost me 4 hours last month.
+
+**Why this adds value:** Reads the actual screenshot, identifies the specific error, gives an actionable fix. Tier-C indie peer (bio: "I build AI trading bots & write about the process"), highest follow-back probability of the day. No TextStack mention — pure community contribution. Ends with shared frustration ("cost me 4 hours") to land empathetically.
+
+---
+
+## Candidate 3 — @RahulGangwani24 (counter-perspective with real data)
+
+**Source post:** https://x.com/RahulGangwani24/status/2054908838931738890
+**Posted:** ~8 minutes before scan
+**Excerpt:**
+> "Can't use Copilot? Run local coding models through Ollama — unlimited free code assistance. Code Llama and DeepSeek Coder run on consumer hardware and integrate with VS Code. No subscription. No cloud dependency. Your code never leaves your machine. Privacy + free."
+
+**Draft reply:**
+> The "consumer hardware" part hides the real cost: latency. On CPU-only laptops, DeepSeek Coder takes 5–15s per completion vs Copilot's <1s. Great for batch refactor, painful for inline autocomplete. Where are you actually using it day-to-day — chat or completions?
+
+**Why this adds value:** Counter-perspective backed by concrete numbers (5–15s vs <1s). Doesn't dismiss the idea, reframes it — there's a real use case for local code models, just not inline autocomplete. Ends with a specific question that forces the OP to clarify their actual usage (which is the more interesting conversation). No TextStack mention.
+
+---
+
+## Candidate 4 — @asiokun3 (Japanese, lower priority but on-topic)
+
+**Source post:** https://x.com/asiokun3/status/2054909601569787921
+**Posted:** ~5 minutes before scan
+**Excerpt (translated from Japanese):**
+> "I'm running Ollama as a server and accessing it from an existing app as an OpenAI-compatible API. The only code change needed is updating the base_url. I was able to switch to local with almost zero migration cost."
+
+**Draft reply:**
+> The base_url swap is the killer feature. We did the same — only gotcha was that some libs hardcode the `gpt-` prefix in model names for routing. Did you hit anything similar, or did your existing app accept a custom model id cleanly?
+
+**Why this adds value:** Validates a real production pattern, adds one specific gotcha (model-name prefix hardcoding) others might hit, ends with a clarifying question. **Caveat:** OP tweets primarily in Japanese — reply in English may go unanswered. Lower priority than #1–3. Post only if user is comfortable with cross-language engagement.
+
+---
+
+## Continued conversations
+
+### @PaulChen088 — Synthadoc / Claude Code CLI Provider
+
+**Original thread:** https://x.com/PaulChen088/status/2053865235941826783 (replying to @Rexetdeus + @buildinpublic)
+**Posted:** May 11 (3 days old — outside the ideal window, but this is the 2nd touch in an active thread, polite to close the loop)
+**Their message:**
+> "It integrates with Claude code and Open code installations using CLI Provider. See this blog for details. [dev.to/synthadoc-your-coding-tool-is-now-your-wiki-brain]"
+
+**Note:** Yesterday's session drafted a near-identical follow-up (re: doc index persistence). If yesterday's draft wasn't posted, post that one rather than this — they're the same intent. If it was already posted, skip this entry today.
+
+**Draft reply (only if yesterday's wasn't posted):**
+> Smart — CLI provider sidesteps the per-user key chore entirely. Quick follow-up: does it fall back gracefully if the user doesn't have Claude Code or OpenCode installed, or is one a hard prereq? That shapes who I'd recommend it to.
+
+**Why this adds value:** Acknowledges Paul's answer, asks a meaningful prereq question (installation requirements) that anyone considering Synthadoc would care about. Keeps the conversation alive without being sycophantic.
+
+---
+
+## New follow-backs
+
+**Notable:** @Mary — followed @Rexetdeus on May 10 and liked 4 posts including the AGPL/DDIA hook post. Not in tier list but worth noting — the DDIA hook continues to land. Account scale unknown.
+
+No other external follow-backs since last session. Total followers: 11 (+9 since baseline).
+
+---
+
+## Candidates considered but rejected
+
+- **@simonw** (8h, "Doing this is a great way to make a bonfire of your reputation" re: AI-generated LinkedIn comments) — visibility window closed, and we already engaged with @simonw earlier this week.
+- **@swyx** (12h reposted talk announcement, 22h "haha openclaw bad" prompt-injection thread) — both outside the 1–3h freshness window.
+- **@arvidkahl** (recent posts are May 9–May 11) — window closed; all top posts are 3+ days old.
+- **@__aplace__** (May 12, local LLM cost-effective token generator) — 2 days old.
+- **@WayneallenEnt** ("$MNFT building in public") — crypto promo, off-tribe.
+- **@jakubmuzzik** ("literally building in public today" + cafe photo) — no substantive content to engage with.
+
+## Suggestions for target list
+
+- Add **@MozillaAI** to Tier B watchlist if today's reply lands — they post Gemma/llamafile updates regularly and the topical overlap with our stack is high.
+- Consider **@TheWordWeaver_** for Tier C if they engage back — indie peer with AI trading bot bio, similar build-in-public energy.
+
+## Calibration notes
+
+- 3rd consecutive session where Following feed signal quality is essentially zero. Recommend the user spend 15 minutes this weekend unfollowing the engagement-bait accounts (Sindirella, Ferarri Prime, generic "Say Hi" accounts) — they're crowding out the signal we actually pay attention to.
+- Follower growth from 2 → 11 in 3 days is consistent with the routine working at expected rate (5–15/week projection). Continue.
+- Top pick today (@MozillaAI) is the only one that warrants the TextStack production-numbers mention. If you only post one reply today, post that one.
diff --git a/docs/marketing/x-routine/2026-05-15.md b/docs/marketing/x-routine/2026-05-15.md
new file mode 100644
index 00000000..7f173fc4
--- /dev/null
+++ b/docs/marketing/x-routine/2026-05-15.md
@@ -0,0 +1,133 @@
+# X reply-game drafts — 2026-05-15 (Friday)
+
+Generated by daily-x-reply-game scheduled task.
+4 fresh candidates selected, 1 continued conversation. Pending user review and per-message approval before posting.
+
+Session notes: Following feed quality remains essentially zero for the 4th consecutive day — engagement-bait reposts (Sindirella, Ferarri Prime, "Drop Hi" growth-hack accounts) dominate the top 20. Live-search on `"local LLM" OR ollama OR gpt-5 -filter:replies lang:en` carried this session, plus three Tier B/C profile scans (@simonw, @theo, @karpathy, @arvidkahl) to spot-check the tribe.
+
+---
+
+## Candidate 1 — @MoureDev (Brais Moure, verified)
+
+**Source post:** https://x.com/MoureDev/status/2055272855705718857
+**Posted:** 13:03 UTC (~2 min before scan — fresh, peak visibility window)
+**Excerpt:**
+> "The AI Workshop on Local from Scratch is now published!
+> - Fundamentals
+> - How to Choose a Model
+> - Installation and Setup of Local Models with LM Studio and Ollama
+> - Using Local Models in VS Code and Claude Code
+> - Recommendations"
+> (links to youtube.com/live/LQqv4M_NtNc)
+
+**Draft reply:**
+> Solid stack — LM Studio for the on-ramp, Ollama once people stop wanting a UI. We landed on gemma4:e2b in prod on a 30 GB CPU VPS (no GPU), p95 ~20ms over a 63k-request load test. The e4b→e2b downgrade only became obvious under concurrent load. Does the workshop touch model-size vs hardware tradeoff?
+
+**Char count:** ~280 (right at limit — trim "Solid stack — " if needed: brings to ~265)
+**Why this adds value:** Specific production data point (model, p95, load test size) that nobody mentions in beginner tutorials. The "e4b→e2b under concurrent load" detail is a real gotcha learners hit later. Ends with a question that prompts a workshop creator to engage. This is **today's single TextStack production-numbers mention** per the daily cap.
+
+**Priority:** P1 — highest value reply of the session. Brais has a verified account with sizable EN+ES following and his workshop overlaps directly with our deployment story.
+
+---
+
+## Candidate 2 — @aterrel (Dr. T, Anaconda)
+
+**Source post:** https://x.com/aterrel/status/2055273129447174572
+**Posted:** 13:04 UTC (~1 min before scan)
+**Excerpt:**
+> "Come join me at @anacondainc booth today for our CUDA Python 1.0 release. I'll be hosting our Local LLM generated DJ party on a Spark DGX from 1:15-3 and presenting on the stack at 4pm! #PyConUS"
+
+**Draft reply:**
+> Spark DGX as a DJ box is a fun flex. What's the inference path driving the set — vLLM, Triton, something custom? Streaming generation per-track or pre-rendering the playlist? Curious where the latency budget actually lives.
+
+**Char count:** ~225
+**Why this adds value:** Asks a real, specific technical question (inference stack + streaming vs pre-render) that a presenter at PyConUS would enjoy answering. Light tone matches the parent post. No TextStack mention — pure community contribution.
+
+**Priority:** P2 — Anaconda employees engage well; lower follower count than Brais but in-tribe.
+
+---
+
+## Candidate 3 — @ollies0x (OLLIE)
+
+**Source post:** https://x.com/ollies0x/status/2055272527728177361
+**Posted:** 13:02 UTC (~3 min before scan)
+**Note:** This is a reply within a thread (replying to @ollies0x's own parent post), not a standalone — engagement risk slightly elevated since the parent context shapes the conversation. Worth a look at the parent thread before posting.
+**Excerpt:**
+> "OpenCode and Ollama Cloud is $30 a month so why would you want a 3090 at this point (yes I know privacy etc.) You'd have to use OpenCode and Ollama Cloud for 40 months before you recoup the cost of a 3090. In 40 months, the tech available is going to be better than a 3090"
+
+**Draft reply:**
+> The 40-month math assumes steady usage. Burst workloads leave the 3090 idle 90% of the time and cloud wins on $/used-hour. Third path nobody talks about: small-model on a CPU-only VPS — kills the GPU-vs-cloud debate for a wide band of use cases.
+
+**Char count:** ~250
+**Why this adds value:** Counter-perspective that doesn't dunk — agrees with their math while adding a missing dimension (usage profile) and a third option (CPU). The "nobody talks about" framing positions us as the missing voice in the conversation. No TextStack name-drop but our deployment IS the implicit example.
+
+**Priority:** P2 — economics threads tend to get good follow-engagement.
+
+---
+
+## Candidate 4 — @swyx (Tier B, 157.5K followers)
+
+**Source post:** https://x.com/swyx/status/2055231013253472418
+**Posted:** 10:17 UTC (~3h before scan — at edge of freshness window)
+**Excerpt:**
+> "also i think the publicly disclosed revenue time series looks like this now btw. project to EOY, closest to the correct EOY ARR prediction gets a like" (attached chart, unseen by us — almost certainly OpenAI/Anthropic/xAI ARR curve)
+
+**Draft reply:**
+> Calling $15B EOY. The pattern that keeps surprising me on this kind of curve is the inflection lands 1–2 quarters after the team itself notices — public time series lags internal vibe by exactly one fundraise.
+
+**Char count:** ~215
+**Why this adds value:** Plays the game swyx is asking for (gives a specific number) AND adds a meta-observation about how ARR curves leak ahead of public disclosure — a take swyx, as a fund-adjacent insider, may actually want to push back on. Optimized for a quote-reply or like from swyx, which compounds visibility 10–100x.
+
+**Priority:** P1 (visibility-weighted) — swyx's reply-game is high-volume and he engages with specific predictions. **Caveat:** we cannot see the chart image — if the curve is obviously not an AI lab (e.g., it's Bitcoin or a public co), the EOY guess needs to change. **User: please skim the image before posting.**
+
+---
+
+## Continued conversations
+
+### @PaulChen088 — Synthadoc / Claude Code CLI Provider (3rd carry-over)
+
+**Original thread:** https://x.com/PaulChen088/status/2053865235941826783 (replying to @Rexetdeus + @buildinpublic)
+**Their message (May 11):**
+> "It integrates with Claude code and Open code installations using CLI Provider. See this blog for details. [dev.to/synthadoc-your-coding-tool-is-now-your-wiki-brain]"
+
+**Status:** Drafted on 2026-05-13 and 2026-05-14, deferred both times. Today is the **third** carry-over. Two options:
+
+1. **Post the existing draft from 5-13/5-14** (re: graceful fallback if Claude Code/OpenCode isn't installed) — closes the loop politely, no fresh wording needed.
+2. **Drop the thread** — 4 days is past the polite-close window. If the user has no intention of posting, deleting from the queue is cleaner than carrying forever.
+
+**Fresh draft (only if option 1 is chosen and yesterday's wording feels stale):**
+> Bookmarked. The CLI-Provider trick sidesteps the per-user key chore entirely — nice call. Quick prereq question: does Synthadoc degrade gracefully if the user has neither Claude Code nor OpenCode installed, or is one of them a hard requirement? Shapes who I'd point at it.
+
+**Recommendation:** post option 1 today or close the loop in the tracker.
+
+---
+
+## New follow-backs
+
+No new external follow-backs since the 2026-05-14 session. Account state appears unchanged from yesterday (11 followers).
+
+Mary's follow + 4-post likes from May 10 was already logged in yesterday's file.
+
+---
+
+## Candidates considered but rejected
+
+- **@simonw** — freshest post is 14h old ("Mitchell's React Native porting" + the @mitchellh quote), outside the 1–3h freshness window. We engaged with him this week already.
+- **@karpathy** — freshest post is May 11 (4 days), window closed.
+- **@theo** — fresh "Subnautica 2 day off" post (1h) is off-topic; the pinned "I cancelled my Claude Code sub" thread is 16h old and has 500+ replies, our reply would drown.
+- **@arvidkahl** — freshest substantive post is May 14 (~18h), outside window.
+- **@distokens** — "How AI founders accidentally destroy margins" thread is on-topic but the account is openly selling AI-cost-reduction infrastructure, so it's a startup-promo thread, not peer dev contribution. Off-tribe.
+- **@mudler_it (Ettore Di Giacinto, LocalAI maintainer)** — fresh reply about llama.cpp community history is substantive but mid-thread, hard to engage cleanly without parent context. **Add to tribe watchlist** for next session — high-relevance maintainer account.
+- **@NVIDIAAI** — replied "Sounds dreamy" to a Local LLM workshop tweet 6 min ago, no substance to engage with from us.
+- **@wakanara** — Japanese-language Ollama post; lower follow-back probability vs effort, especially since we tried a JP candidate yesterday (@asiokun3) and got no response yet.
+
+## Suggestions for target list
+
+- **Add @mudler_it (LocalAI maintainer)** to Tier B watchlist — high topical overlap, posts daily about the local LLM ecosystem.
+- **Add @MoureDev** to Tier B watchlist if today's #1 reply lands — verified, large EN/ES bilingual reach, workshop content overlaps our stack monthly.
+
+## Calibration notes
+
+- **Following feed unusable for the 4th straight session.** Reiterating yesterday's recommendation: ~15 min unfollowing the engagement-bait accounts (Sindirella, Ferarri Prime, Zinariya/@Oxboddi, Christiana, Lily_9010, Isabella9zox, Nitya_4u) would meaningfully reclaim the signal. Live-search + tribe profiles will continue carrying these sessions regardless.
+- **No reply yet from @asiokun3** to yesterday's base_url-prefix gotcha question — JP-language reply takes longer; check again Monday.
+- **No new follow-backs from yesterday's 4 posts.** That's the expected pattern at this scale — follower lift comes from likes/quote-replies on the *reply itself*, which take 24–72h to surface.
diff --git a/docs/marketing/x-routine/2026-05-18.md b/docs/marketing/x-routine/2026-05-18.md
new file mode 100644
index 00000000..ed110530
--- /dev/null
+++ b/docs/marketing/x-routine/2026-05-18.md
@@ -0,0 +1,105 @@
+# X reply-game drafts — 2026-05-18 (Monday)
+
+Generated by daily-x-reply-game scheduled task.
+5 candidates selected. Pending user review and per-message approval before posting.
+
+Account state at run time: 10 followers (up from 2 at baseline), 99 following, 36 posts. Notifications quiet since May 11.
+
+---
+
+## Candidate 1 — @cwwhitehead (TextStack mention — use this one for the daily prod-numbers reference)
+
+**Source post:** https://x.com/cwwhitehead/status/2056359150611726471
+**Posted:** ~15 min ago (9:00 AM EDT)
+**Parent:** Reply to @tobi (Tobi Lütke, Shopify CEO, 469K followers — high reply-chain visibility)
+**Chris bio:** Head of AI @ LXN
+**Excerpt:** "Why qwen and not Gemma 4?"
+
+**Draft reply:**
+> We ran the opposite path — qwen3:8b → gemma4:e4b → e2b. e2b held p95 ~20ms over a 63k-request load on a single CPU VPS; e4b couldn't keep up under concurrent load. qwen edged it on quality but the latency tax wasn't worth it for our use case.
+
+**Char count:** 268
+**Why this adds value:** Answers Chris's question with the exact comparison he's asking about, backed by Vasyl's own migration numbers. Tobi's audience reads his thread replies. This is the day's single TextStack production-numbers mention.
+
+---
+
+## Candidate 2 — @tallhamn (Marcus Tallhamn, verified, indie dev)
+
+**Source post:** https://x.com/tallhamn/status/2056358574289231964
+**Posted:** ~15 min ago
+**Parent:** Reply to @antirez (Salvatore Sanfilippo, Redis creator). antirez's parent post — "Imagine a local agent where cache misses don't exist, tools don't need translations, you see progress for prefill, tokens are emitted ASAP" — has 276 likes, 21K views.
+**Excerpt:** "Performance aside a solid vertically integrated coding stack would make it so much easier for people to solve application problems with local AI. I've spent a ton of time experimenting and benching Claude/codex/opencode/pi + llama.cpp/ollama/lmstudio + middleware for API glue and performance monitoring (tps etc) + qwen/ds/... with various configs"
+
+**Draft reply:**
+> +1 on middleware pain — once you swap providers, the glue layer becomes the product. Per-token cost & latency don't live in the same dashboard as app SLOs, so failures look like "random slowness" for weeks.
+
+**Char count:** 211
+**Why this adds value:** Validates Marcus's pain point and adds the specific observability gap that doesn't get talked about. antirez's reply-chain is high-visibility; no TextStack mention keeps it pure community contribution.
+
+---
+
+## Candidate 3 — @levelsio (Tier A, 868K followers)
+
+**Source post:** https://x.com/levelsio/status/2056355830778261651
+**Posted:** ~30 min ago
+**Excerpt:** "Testing a bit with 3d terrain on Hoodmaps today. You can actually see the favelas in Rio pretty well, this is Rocinha, it's kinda hidden between a mountain top and another one..."
+
+**Draft reply:**
+> What's powering the elevation — Mapbox terrain-rgb tiles, or are you sampling SRTM directly? Curious whether you went raster or vector mesh for the favela density bits.
+
+**Char count:** 168
+**Why this adds value:** Specific technical question on his build choices. levelsio responds to good implementation questions in his replies. Implicit invitation for him to share, which keeps reply-chain visibility going.
+
+---
+
+## Candidate 4 — @dotnet (339K followers, primary stack)
+
+**Source post:** https://x.com/dotnet/status/2056355426396995709
+**Posted:** ~30 min ago
+**Excerpt:** "Coding standards aren't 'nice to have' — they're performance features. Today's On .NET LIVE, we take you through project structure to API shape to async patterns..."
+
+**Draft reply:**
+> The biggest async one nobody enforces: ConfigureAwait(false) in library code. Lint-rule it, save your future self a year of deadlocks when someone uses your code from a SynchronizationContext.
+
+**Char count:** 196
+**Why this adds value:** Concrete, contentious-enough .NET pattern most teams under-weight. Likely to prompt either agreement or "actually, AsyncLocal now…" replies — both are good for thread visibility.
+
+---
+
+## Candidate 5 — @VitalikButerin (6.3M followers)
+
+**Source post:** https://x.com/VitalikButerin/status/2056354141832626487
+**Posted:** ~35 min ago
+**Excerpt:** "Many people have claimed that with AI-assisted bug finding, secure code (and hence trustless anything) will be impossible. I have a much more optimistic take, and AI-assisted formal verification is a major part of the reason why: [links shallow dive into formal verification]"
+
+**Draft reply:**
+> Curious where you'd draw the line — AI-assisted spec writing is the harder gap IMO than the verification step itself. The proof checker is rigorous; the question is whether the spec actually captures intent. Are you betting on LLMs closing that gap?
+
+**Char count:** 249
+**Why this adds value:** Thoughtful divergent take phrased as a question, on a well-known limit of formal methods (the spec problem). Frames as inquiry not contradiction — keeps Vitalik likely to engage. Massive visibility if he or anyone in the thread replies.
+
+---
+
+## Continued conversations
+
+### Paul Chen @PaulChen088 — May 11 (1 week old, still unattended)
+
+**Original thread:** Vasyl tagged @buildinpublic; Paul Chen replied with: "It integrates with Claude code and Open code installations using CLI Provider. See this blog for details." Linked dev.to article "Synthadoc: Your Coding Tool Is Now Your Wiki Brain."
+
+**Draft continued reply:**
+> Just read it — CLI provider angle is clever. How are you handling rate-limit/cost when wiki crawls hit Claude's API at scale? That's the bit that nuked my first attempt at something similar.
+
+**Char count:** 192
+**Why this is worth catching up on:** External, on-topic developer. A week old but the reply slot is unclaimed, and continuing the thread keeps the relationship warm + signals attention. Lower priority than today's fresh candidates.
+
+---
+
+## New follow-backs since baseline
+
+- Followers grew 2 → 10 since May 12 (flagship article publication). No specific notable accounts surfaced in the May 10-11 notification window — mostly the @gl1tchmary alt and one "Mary" follow (likely same alt).
+
+## Proposed additions to target list
+
+- **@antirez** (Salvatore Sanfilippo, Redis creator) — already prominent in the local-agent conversation tribe; high gravitational pull on AI-coding discussion. Worth adding to Tier E.
+- **@cwwhitehead** (Head of AI @ LXN) — small but in-tribe, posts on local LLM topic. Worth adding to Tier B.
+- **@tallhamn** (Marcus Tallhamn, "Code slinger for robot swarms") — verified indie dev benching local AI stacks. Worth adding to Tier C.
diff --git a/docs/marketing/x-routine/2026-05-19.md b/docs/marketing/x-routine/2026-05-19.md
new file mode 100644
index 00000000..0214151d
--- /dev/null
+++ b/docs/marketing/x-routine/2026-05-19.md
@@ -0,0 +1,105 @@
+# X reply-game drafts — 2026-05-19
+
+Generated by daily-x-reply-game scheduled task.
+4 candidates selected + 1 continued conversation. Pending user review and
+per-message approval before posting.
+
+Notes on this session:
+- Following-tab signal was weak — heavy crypto/sports noise, only 1 strong
+ candidate from Tier-A targets (@levelsio). Backfilled via live search on
+ `ollama lang:en min_faves:5` and `"local LLM" OR gemma OR ollama OR
+ "Claude Code"`.
+- 1 reply mentions TextStack production experience (the @DAlistarh one) —
+ within the "max 1/day" budget. The rest are pure community contribution.
+- Notifications check: no new follow-backs since 2026-05-15. Follower count
+ is 8 (up from baseline 2 on 2026-05-12, so +6 over the week).
+
+---
+
+## Candidate 1 — @DAlistarh (Tier B, ~4h old) ⭐ TextStack-mention reply
+
+**Source post:** https://x.com/DAlistarh/status/2056661176843436421
+**Posted:** ~4 hours ago
+**Excerpt:** "Weight-only quantization powers local LLMs like llama.cpp or Ollama. But SOTA quantized accuracy requires complex kernels that are notoriously hard to implement. Can we get SOTA accuracy and keep things simple? Our new GSQ (Gumbel-Softmax Quantization) method says yes."
+
+**Draft reply:**
+> We run gemma4:e2b via Ollama on a CPU-only VPS — downgraded from e4b because concurrent throughput was the bottleneck, not accuracy. Curious how GSQ kernels compare to Q4_K_M on pure-CPU inference, where most indie deployments actually live.
+
+**Char count:** 245
+**Why this adds value:** Real-world deployment constraint (CPU-only, concurrency-bound) that the paper's accuracy framing doesn't address. Asks a specific kernel comparison question that invites a technical follow-up. Positions us as someone with skin in the local-LLM game without naming the product.
+
+---
+
+## Candidate 2 — @levelsio (Tier A, ~1h old)
+
+**Source post:** https://x.com/levelsio/status/2056705090073870460
+**Posted:** ~1 hour ago
+**Excerpt:** "A nice way to stay safe is to ask Claude Code to audit your devices. I do same on my VPS servers, so today I tried it on my MacBook Pro and it's pretty good at it too. It founds lots of stuff that was not secured, I actually forgot to enable FileVault when I got this new MBP..."
+
+**Draft reply:**
+> Same approach found a stale nginx rule on mine I'd assumed was localhost-only — it wasn't. Did the audit catch anything you wouldn't have looked for manually, or mostly things you already knew you'd been sloppy about?
+
+**Char count:** 220
+**Why this adds value:** Concrete confirming data point (stale nginx rule, not-actually-localhost) + a sharp question that splits "Claude Code as net-new auditor" vs "Claude Code as procrastination-killer." Either answer is a reply hook. Tier-A reply visibility on a fresh post is the highest-EV slot today.
+
+---
+
+## Candidate 3 — @imikerussell (mid-tier, ~5min old at scan time)
+
+**Source post:** https://x.com/imikerussell/status/2056722925634322670
+**Posted:** ~5 minutes ago (top of thread; we're replying to Step 3 in the thread)
+**Excerpt:** Multi-step thread on rebuilding his YouTube studio with AI. Step 3: "I gave Claude Code control of my studio lights. Plugged it into Home Assistant (Elgato + Hue). Told it to cycle scenes and iterate until my live audience approved. 30+ variations. My hands never touched a dimmer."
+
+**Draft reply:**
+> Step 3 is the one that breaks my brain — the audience-feedback loop is what makes it work, not the API access. Did you give it a "stop iterating" criterion, or just bail when you liked the result? Curious how you avoided cost-of-iteration blowing up.
+
+**Char count:** 248
+**Why this adds value:** Identifies the actually-novel part of his setup (feedback loop, not the HA integration which is well-trodden), and asks the engineering question that anyone running iterative LLM loops in prod cares about: stop condition. Likely to land in a thread where his followers are already paying attention.
+
+---
+
+## Candidate 4 — @JulianGoldieSEO (~2h old)
+
+**Source post:** https://x.com/JulianGoldieSEO (profile — search bar to locate; thread starts "LOCAL COMPUTER USE AGENTS ARE FINALLY REAL")
+**Posted:** ~2 hours ago
+**Excerpt:** "LOCAL COMPUTER USE AGENTS ARE FINALLY REAL. You can now run an AI agent on your own machine that opens apps, writes notes, browses, and works in the background. But the setup breaks if you miss one step. The Local Agent Stack: → Ollama runs the local model on your machine →..."
+
+**Draft reply:**
+> That "setup breaks if you miss one step" line is the entire local-LLM story. The Ollama → app glue is where 80% of the deployment debugging actually lives, not the model itself. What broke for you that took longest to find?
+
+**Char count:** 225
+**Why this adds value:** Validates his framing while shifting attention to the actually-load-bearing engineering insight (glue is harder than the model). The closing question is generous — gives him a chance to share a war-story which is good thread fuel.
+
+---
+
+## Continued conversations
+
+### @PaulChen088 — pending reply from May 11
+
+**Their reply on our @buildinpublic thread:**
+> "It integrates with Claude code and Open code installations using CLI Provider. See this blog for details." (links Synthadoc blog on dev.to)
+
+**Note:** This is 8 days old, which is past the "fresh engagement" window for most reply chains, but reciprocity matters and Paul replied to one of our own posts. Borderline whether to engage; included for your call.
+
+**Draft reply:**
+> Synthadoc looks like a similar bet on the "ground the LLM in your codebase, not the open web" pattern. Curious if you're seeing latency from the CLI provider hop, or if it's mostly fine for interactive use.
+
+**Char count:** 207
+
+---
+
+## Target list — proposed additions
+
+None this session — the Tier A/B list still pulls in good content when we
+backfill via live search. The bigger problem is that the Following tab is
+crypto-heavy; consider unfollowing CoinMarketCap and Bitcoin to declutter,
+or moving high-signal accounts to a Twitter List for faster scanning.
+
+---
+
+## Posting order recommendation
+
+If only posting 1: **@levelsio** (Tier A, fresh, highest visibility).
+If posting 2: add **@imikerussell** (fresh thread, mid-tier author, technical question hooks into engineering audience).
+If posting 3-4: add **@DAlistarh** (TextStack mention budget) and **@JulianGoldieSEO** (lower-tier but on-topic).
+Skip the @PaulChen088 continued conversation unless you specifically want to reciprocate — it's stale.
diff --git a/docs/marketing/x-routine/2026-05-20.md b/docs/marketing/x-routine/2026-05-20.md
new file mode 100644
index 00000000..0435044c
--- /dev/null
+++ b/docs/marketing/x-routine/2026-05-20.md
@@ -0,0 +1,107 @@
+# X reply-game drafts — 2026-05-20 (Wednesday)
+
+Generated by daily-x-reply-game scheduled task.
+4 candidates selected + 3 continued conversations. Pending user review and per-message approval before posting.
+
+**New follow-backs:** none. Notifications quiet — only @PaulChen088 (May 11, promo reply w/ dev.to link — skip, also flagged stale in prior logs) and @gl1tchmary activity (own alt account). Account state: **100 following / 6 followers** (−2 vs May 19's 8 — slow churn continuing).
+
+**Feed note (6th session running):** Following tab still stale — top posts were Elon (22h), then May-18 reposts, Karpathy 21h, Theo 9h, Rauch 23h. Nothing in the 1–3h window from the Following tab. Live search (`"local LLM" OR ollama OR gemma`, then quality-filtered variants) + direct tribe-profile scans (@simonw, @theo, @swyx, @levelsio, @arvidkahl) carried the harvest again. Freshest genuine candidate is Theo at ~3h; the rest are 11–14h (still inside their audience's visibility window given a quiet Wed-morning EDT slot). Standing suggestion from May 19 still applies: move high-signal accounts into a dedicated List so the Following tab stops being noise.
+
+---
+
+## Candidate 1 — @theo *(Tier B — top pick, freshest)*
+
+**Source post:** https://x.com/theo/status/2057029377310052521
+**Posted:** ~3 hours ago (quote-tweet of @seconds_0)
+**Excerpt:** "Wait wtf, they STILL haven't updated pretraining???" — QT of a post noting Gemini 3.5 Flash ships with a Jan 2025 knowledge cutoff (17 months stale) despite a May 19 2026 release; it's a refinement of the 3 Pro base, not a fresh pretrain.
+
+**Draft reply:**
+> Knowledge cutoff barely matters for agentic work if you pipe current docs into context. The real cost of stale pretraining: the model confidently autocompletes deprecated APIs it "remembers." Retrieval fixes facts, not muscle memory. Seeing the same?
+
+**Why this adds value:** Reframes the complaint with a concrete failure mode (deprecated-API hallucination) instead of piling onto "stale = bad" — gives Theo a specific thing to react to. Fresh post, high reply visibility, no TextStack mention (pure community contribution).
+
+---
+
+## Candidate 2 — @simonw *(Tier B/E)*
+
+**Source post:** https://x.com/simonw/status/2056867815605625172
+**Posted:** ~14 hours ago
+**Excerpt:** "My notes on Gemini 3.5 Flash – 3x the price of Gemini 3 Flash but Google are planning to use it for many of their own products."
+
+**Draft reply:**
+> The 3x jump is the interesting part — "Flash" used to be the no-brainer cheap default. At any real volume that bump flips the math from "just upgrade" to "benchmark whether 3 Flash is still good enough." Curious if the quality delta actually justifies it.
+
+**Why this adds value:** Adds the build-vs-buy / volume-economics angle Simon writes about constantly, and ends on an open question about the eval delta — invites his take rather than just agreeing. No TextStack mention.
+
+---
+
+## Candidate 3 — @DivyanshT91162 *(local-LLM topic match — carries today's TextStack mention)*
+
+**Source post:** https://x.com/DivyanshT91162/status/2056910886108021119
+**Posted:** ~11 hours ago
+**Excerpt:** "...local AI still feels like a developer hobby: install Ollama → fight dependencies → pray your GPU survives." (post then shares the nay-cat/ChromeChat repo)
+
+**Draft reply:**
+> The "pray your GPU survives" part is the myth worth killing — you often don't need one. We run gemma4:e2b in prod on a CPU-only 30GB VPS, no GPU, p95 ~20ms across a 63k-request load test. Right-sizing the model beats throwing hardware at it. What's your stack landing on?
+
+**Why this adds value:** Directly counters the post's central assumption (local LLM = GPU pain) with hard production numbers — genuinely useful to anyone reading. This is the day's single TextStack-experience mention.
+
+**Caveat for review:** @DivyanshT91162 is a high-volume AI-news/repo-curation account (~2,900 posts, multiple/hour), not a peer builder — lower follow-back value. Reply still reaches post readers. If you'd rather not engage a news-farm account, skip this one and the session still stands at 3 strong candidates with no TextStack mention (the routine caps it at 1/day — zero is fine).
+
+---
+
+## Candidate 4 — @arvidkahl *(Tier A — open-source / supply-chain security)*
+
+**Source post:** https://x.com/arvidkahl/status/2056912488159936956
+**Posted:** ~11 hours ago (quote-tweet of @github)
+**Excerpt:** "We have wormsign. Did Shai Hulud strike at the source?" — QT of GitHub announcing it is investigating unauthorized access to its internal repositories.
+
+**Draft reply:**
+> The unsettling part isn't one more bad package — it's the blast radius moving up to the host itself. Pinned versions and lockfiles defend against bad publishes; they don't help when the trust root itself is in question. Not sure what defense-in-depth even looks like there.
+
+**Why this adds value:** Elevates the thread from "another breach" to the structural point (host compromise vs package compromise breaks your usual lockfile defenses) — a real concern for any OSS maintainer. Divergent-take ending invites Arvid to weigh in. No TextStack mention. Note: kept deliberately general — avoids asserting specifics about the Shai-Hulud worm.
+
+---
+
+## Continued conversations
+
+These are external-user replies that landed on Vasyl's prior replies (posted May 14–15; this is the first session checking reciprocity on them). None are @gl1tchmary.
+
+### Continued 1 — @TheWordWeaver_ (GhostPen)
+
+**Thread:** https://x.com/Rexetdeus/status/2055068233107927261
+**They replied:** "Thanks so much man. I really appreciate. Would get on it now." (on Vasyl's framer-motion ESM debug tip)
+
+**Draft continued reply:**
+> Anytime. One more if it still fights you after the version pin: check that framer-motion isn't getting double-bundled — Vercel's tree-shaking trips on mixed CJS/ESM and you get two copies fighting over context. Ping me how it goes.
+
+**Why:** Turns a thank-you into a second concrete tip + an open door — keeps a build-in-public peer (bio: builds AI trading bots) warm.
+
+### Continued 2 — @ollies0x (OLLIE)
+
+**Thread:** https://x.com/Rexetdeus/status/2055411803941658635
+**They replied:** "Yeah, very interesting, and good point. I personally think that the 'home' (and vps) tech will advance quicker than the current tech... Making home tech, possibly, riskier."
+
+**Draft continued reply:**
+> Right — that's the real case for renting or small-model-on-VPS: you're not betting 40 months on one GPU generation. Hardware depreciation is the line item nobody prices in. Cloud/VPS turns capex guesswork into a monthly opex you can cancel.
+
+**Why:** Extends his point (fast-moving tech → ownership risk) with the capex-vs-opex framing — substantive, peer-level, keeps a real GPU-vs-cloud discussion going.
+
+### Continued 3 — @RahulGangwani24 (Rahul)
+
+**Thread:** https://x.com/Rexetdeus/status/2055068537601822838
+**They replied:** "Latency on CPU laptops was never the surprise... Most people using Ollama daily lean on chat-style workflows: refactors, debugging, test generation and not Copilot-style keystroke autocomplete."
+
+**Draft continued reply:**
+> That tracks — and it reframes the whole "local can't replace Copilot" debate. They're different tools: one is keystroke-latency-bound, the other throughput-bound. Local wins the second category outright. The autocomplete comparison was always apples-to-oranges.
+
+**Why:** He answered Vasyl's earlier question directly; this validates and sharpens it into a reusable framing (latency-bound vs throughput-bound) — a clean note to end or extend the thread on.
+
+---
+
+## Posting checklist (for the user)
+
+- Each reply needs explicit approval before posting — reply with which numbers to post (e.g. "post 1, 2, continued 2").
+- All drafts: 100–280 chars, no links, no emojis. Only Candidate 3 mentions TextStack production numbers (the daily max of 1).
+- Don't post all at once — space them out to avoid spam-pattern detection.
+- Top pick if posting only one: **Candidate 1 (@theo)** — freshest (~3h), highest reply visibility, on the day's biggest dev story (Gemini 3.5 Flash).
diff --git a/docs/marketing/x-routine/2026-05-21.md b/docs/marketing/x-routine/2026-05-21.md
new file mode 100644
index 00000000..9adeb773
--- /dev/null
+++ b/docs/marketing/x-routine/2026-05-21.md
@@ -0,0 +1,105 @@
+# X reply-game drafts — 2026-05-21 (Thursday)
+
+Generated by daily-x-reply-game scheduled task.
+4 candidates selected + 0 new continued conversations (3 carry-overs noted).
+
+**STATUS — updated 2026-05-21 (posting session): 3 POSTED, 1 SKIPPED.** Candidates 1, 2, 4 posted with user approval. Candidate 3 (@NikkiSiapno) skipped — the full post turned out to be a paid-partnership ad (#AtlassianPartner #Ad); user chose skip (no pure promo). First posting session since May 15 — user flagged that May 18–21 drafts were never posted and the account decayed 11→6.
+
+**New follow-backs:** none. Notifications quiet — top item is still @PaulChen088 (May 11, promo reply w/ dev.to link — skip, stale carry-over). Nothing new since May 11. Account state: **101 following / 6 followers** (followers flat vs May 20; +1 following).
+
+**Feed note (7th session running):** Following tab still stale — top posts were Elon (4h, "Try Composer 2.5" — promo, not substantive), BridgeMind (23h), Eytan Seidman/Shopify (19h), Mitchell Hashimoto via Theo (14h), VS Code (16h). Nothing in the 1–3h window from the Following tab. Live search carried the harvest again: `ollama OR "local model" OR "local LLM" (inference OR prod OR deploy OR GPU OR CPU) -filter:replies`, a build-in-public variant, and a `"Claude Code" OR "AI coding"` variant. Direct tribe-profile scan (@simonw) confirmed the tribe is quiet too — his freshest post is 21h old. Standing suggestion from May 19/20 still applies: move high-signal accounts into a dedicated List so the Following tab stops being noise.
+
+**Visibility caveat:** the two freshest candidates (1 and 2) are small accounts caught minutes after posting — high freshness, low current reach. They are strong "prompt a reply from the OP" bets (small accounts almost always see and answer replies) but weak "reach a big audience" bets. Candidates 3 and 4 are larger accounts / older posts with wider reach. This is the usual fresh-vs-reach tradeoff when the target tribe isn't posting.
+
+---
+
+## Candidate 1 — @faradaymachines *(top pick — freshest, on-topic)* — [POSTED 2026-05-21]
+
+**Source post:** https://x.com/faradaymachines/status/2057446794749972721
+**Posted:** ~minutes ago when scanned (2026-05-21 ~13:02 UTC)
+**Excerpt:** "chrome canary shipping with local llm support is wild. not a toy — actual model running on your machine. browser-level local inference means soon every webpage will have an ai copilot without phoning home. firefox when?"
+
+**Draft reply:**
+> Privacy win is real, but you inherit whatever model the browser ships — no swapping for the task. And once every tab wants inference at once, contention becomes the bottleneck, not capability. Does Canary sandbox that per-tab?
+
+**Why this adds value:** Doesn't just cheer the privacy angle — adds two concrete failure modes (no per-task model choice, resource contention across tabs) and ends on a specific technical question. No TextStack mention.
+
+---
+
+## Candidate 2 — @noguchis *(local-LLM topic match — carries today's TextStack mention)* — [POSTED 2026-05-21]
+
+**Source post:** https://x.com/noguchis/status/2057446737145671742
+**Posted:** ~minutes ago when scanned (2026-05-21 ~13:02 UTC) — post [3/4] of the author's own thread
+**Excerpt:** "[3/4] Axis 2 — split models by role. Opus = orchestrator. Sonnet = implementer. Local LLM (Ollama) = a gate placed *before* spending Claude tokens. Pre-screen the diff locally first → zero token cost, and sensitive data never leaves the machine."
+
+**Draft reply:**
+> This gate pattern holds up in prod — we run gemma4:e2b on a CPU-only VPS as exactly that pre-screen layer: p95 ~20ms over a 63k-request load test. Key is keeping the local model small enough that the gate adds no latency — e4b buckled under concurrency, e2b held.
+
+**Why this adds value:** Validates the author's architecture with hard production numbers and adds the non-obvious gotcha (the local gate only works if the model is fast enough not to add latency — concretely the e4b→e2b lesson). This is the day's single TextStack-experience mention.
+
+**Caveat for review:** Small account, post is a mid-thread reply (~7 views when scanned) — low reach, but a thoughtful builder and a near-certain "OP replies" bet. If you'd rather not spend the daily TextStack mention on a low-reach post, skip this one — the routine caps it at 1/day and zero is fine; the session still stands at 3 strong candidates.
+
+---
+
+## Candidate 3 — @NikkiSiapno *(dev educator — wider reach)* — [SKIPPED 2026-05-21 — paid-partnership ad]
+
+**Source post:** https://x.com/NikkiSiapno/status/2057440161315868673
+**Posted:** ~33 minutes ago when scanned
+**Excerpt:** "AI coding agents are only as good as the context they have. Atlassian just solved that with Cursor in Jira. Context is what makes agents actually useful. Atlassian holds the full context of work: tickets, specs, decisions, and the teams behind it all..."
+
+**Draft reply:**
+> Agreed context is the unlock, but volume isn't the same as signal. Jira is also where specs go stale and decisions get reversed three comments deep. Feeding an agent all of it can bury the relevant 5% — curation beats raw access. How's it scoping what's current?
+
+**Why this adds value:** Offers a genuine counter-perspective ("more context" ≠ "better context" — context rot / stale-ticket noise) rather than agreeing. Larger account = better reach than candidates 1–2. Ends on a question that invites her take. No TextStack mention.
+
+**SKIPPED — reason:** On opening the full post it carried a "Paid partnership" label and ended with "#AtlassianPartner #Ad" — it is a sponsored ad, not an organic post. The search-result excerpt cut this off. Replying under paid promo is off-strategy (routine rule: no pure promo), and a counter-take under someone's paid ad reads as needlessly combative. User confirmed skip. See campaign-tracker learning #7.
+
+---
+
+## Candidate 4 — @hiouso *(build-in-public — real question, in 1–3h window)* — [POSTED 2026-05-21]
+
+**Source post:** https://x.com/hiouso/status/2057413037762453697
+**Posted:** ~2 hours ago when scanned
+**Excerpt:** "founders/builders — would this actually be a good business idea or just peak build in public brainrot? a tool tracking founder metrics like: screenshots-to-users ratio, domains owned to paying customers, 'just one more feature' velocity, coffee to shipped features..."
+
+**Draft reply:**
+> The funny ones (coffee:features, domains owned) are screenshot bait — fun, unpaid. The one that'd actually drive decisions is time-from-idea-to-first-paying-user. Problem: that's the metric founders least want to look at. Build for the painful number, not the shareable one.
+
+**Why this adds value:** Answers the author's actual question (good business idea or brainrot?) with a real product-direction take — separates vanity metrics from the one decision-driving metric, and names the adoption risk (fun-but-unpaid). Substantive, honest, peer-level. No TextStack mention.
+
+---
+
+## Continued conversations
+
+No **new** external replies landed on Vasyl's prior replies since the May 20 reciprocity pass. Carry-overs still pending user action:
+
+- **@ollies0x (OLLIE)** — substantive GPU-vs-VPS counter; continued reply already drafted in `2026-05-20.md` → "Continued 2". Still pending. No re-draft today (would duplicate).
+- **@TheWordWeaver_ (GhostPen)** — "thanks" + second tip drafted in `2026-05-20.md` → "Continued 1". Still pending.
+- **@RahulGangwani24 (Rahul)** — latency-bound vs throughput-bound framing drafted in `2026-05-20.md` → "Continued 3". Still pending.
+- **@PaulChen088** — promo reply with dev.to link; 7th carry-over. Recommend skip (stale, promotional, links out).
+
+If you want those continued replies posted, approve them against the `2026-05-20.md` file — they don't need re-drafting.
+
+---
+
+## Tribe watchlist — propose adding
+
+- **@NikkiSiapno** — dev educator (Level Up Coding), large following, posts consistently on AI engineering / coding agents. Good Tier B candidate; would surface in future Following-tab scans if followed. *(Following changes profile state — user decision only; never auto-followed.)*
+
+---
+
+## Posting result (2026-05-21)
+
+Posted with user approval, spaced out, all confirmed sent by X:
+
+- **Candidate 1 — @faradaymachines** — posted. https://x.com/faradaymachines/status/2057446794749972721
+- **Candidate 2 — @noguchis** — posted (carried the daily TextStack prod-numbers mention). https://x.com/noguchis/status/2057446737145671742
+- **Candidate 4 — @hiouso** — posted. https://x.com/hiouso/status/2057413037762453697
+
+Skipped:
+
+- **Candidate 3 — @NikkiSiapno** — skipped, paid-partnership ad (see candidate's SKIPPED note above).
+
+Carry-over continued conversations (@ollies0x, @TheWordWeaver_, @RahulGangwani24 from `2026-05-20.md`) — still unposted; not actioned this session.
+
+**Follow-up:** check these three posts in 1–2 days for likes from non-followers / replies from the OP (the routine's success metric). Reciprocity on any responses gets picked up by the next session's `/with_replies` scan.
diff --git a/docs/marketing/x-routine/2026-05-22.md b/docs/marketing/x-routine/2026-05-22.md
new file mode 100644
index 00000000..5ac1c40b
--- /dev/null
+++ b/docs/marketing/x-routine/2026-05-22.md
@@ -0,0 +1 @@
+Chrome MCP unavailable — skipped today's session
diff --git a/docs/seo/audit-2026-05-14.md b/docs/seo/audit-2026-05-14.md
new file mode 100644
index 00000000..7a763d83
--- /dev/null
+++ b/docs/seo/audit-2026-05-14.md
@@ -0,0 +1,144 @@
+# SEO Audit — 2026-05-14
+
+Источники: GSC (textstack.app), GA4 (property 532821906), Ahrefs Site Audit (project 9661893).
+
+## TL;DR
+
+Технический фундамент в основном на месте (SSG работает, sitemap корректный, schema стоит, indexing strategy — noindex chapter pages, index только метадата — это правильно). Проблема в **двух местах**: (1) индексируемых метадата-страниц мало и часть из них тонкие — Google не считает их достойными индекса; (2) реального organic-трафика почти нет — 44 клика за 3 месяца, средняя позиция 59 (страница 6). До 50k clicks/month отсюда расти в 1000+ раз. Реалистичный путь — 12-24 месяца через scaling индексируемых страниц до 2K+ с сильным контентом и hub-страницы под информационный intent.
+
+## Текущее состояние (snapshot 2026-05-14)
+
+**GSC (3 месяца):**
+- Total clicks: 44
+- Total impressions: 1.09K
+- Average CTR: 4%
+- Average position: 59 (страница 6+)
+- Indexed pages: 327
+- Not indexed: 3.3K (из них 2,102 — noindex by design на chapter pages; ~890 — legacy URLs из периода поломки SEO, выгорят сами)
+- Sitemap: index с 4 sub-sitemaps (books.xml, authors.xml, genres.xml, pages.xml), всего 391 URLs = реальная индексируемая поверхность сайта. 327/391 = **84% indexed coverage — здоровое соотношение**.
+- Core Web Vitals: not enough usage data (трафика мало для Chrome UX Report)
+
+**Top queries (по impressions):**
+- `textstack` — 3 clicks / 134 impr / pos 6.1 (бренд)
+- `complete novels of james joyce` — 0 / 61 / pos 89.9
+- `barchester towers` — 0 / 29 / pos 67.4
+- `mary shelley books` — 0 / 17 / pos 72.9
+- Ещё ~350 author/book запросов на позициях 60-99 (страница 7-10) → 0 кликов
+
+**GA4 (28 дней):**
+- Active users: 7.4K, New users: 7.8K, Event count: 38K
+- Sessions: 7,493 total
+ - Direct: 7,222 (96.38%), avg engagement 1s → шум/боты/misattribution, не реальные люди
+ - Unassigned: 227 (3.03%), 5m 13s engagement → реальные пользователи
+ - Organic Search: 66 (0.88%), 33s engagement → ~2/день настоящего search-трафика
+ - Referral: 10, Organic Social: 2
+- Топ страницы по views: Clean Code (2.1K), Reader (1.7K), Vocabulary (1.3K), Clean Code Focus (1.1K), My Library (555)
+- Bounce rate на топ-страницах 1.4%-33% (хорошо, контент не отталкивает)
+
+**GA4 anomaly — RESOLVED**: 23 апреля 2026 Direct sessions упали с 647 до 1, USA sessions — 671→0. Причина: сайт был добавлен в каталог, лиший ботов под видом Direct трафика. После удаления из каталога метрики нормализовались. **Implication**: 7,222 Direct sessions с 1s engagement за 28-дневное окно — почти все боты (период до 23 апреля). Реальный baseline после очистки: ~10-20 direct + 2 organic + 8 engaged Unassigned = **~30 реальных пользователей/день**.
+
+**Ahrefs Site Audit (12 May):**
+- Health Score: 87/100
+- Crawled URLs: 2,482 (Internal 2,021, External 100, Resources 361)
+- Errors: 445 (308 URLs affected), Warnings: 3,025, Notices: 7,979
+
+**Top Ahrefs Errors:**
+- Page has links to broken page — 171 страниц (+106 new) ↑ растёт быстро
+- 404 page — 126 (+73 new) ↑
+- 4XX page — 126 (+73 new) ↑
+- Duplicate pages without canonical — 11 (+6 new)
+- Page has no outgoing links — 11 (+6 new)
+
+**Top Ahrefs Warnings (низкоприоритетное по большей части):**
+- Noindex page — 1,490 (by design — chapter pages, игнорируем)
+- Low word count — 11
+- Meta description too long — 11
+- H1 tag missing/empty — 7
+
+## Что работает
+
+- SSG отдаёт корректные HTML, sitemap гигиена в порядке (84% indexed из submitted)
+- Indexing strategy `noindex` на chapter pages корректна — избегаем duplicate с Gutenberg
+- Schema, breadcrumbs, FAQ enhancements активны (есть Breadcrumbs и FAQ секции в GSC nav)
+- Контент на топ-страницах удерживает пользователей (bounce 1-33%)
+- Brand search существует (`textstack` импрессий 134 за 3мес, медленно растёт)
+
+## Critical issues (P0) — блокеры роста
+
+### 1. Индексируемая поверхность слишком мала
+
+Sitemap = 391 URLs (вся индексируемая поверхность сайта), из них 327 проиндексировано = 84% coverage. Это здоровое соотношение, но **потолок 391 страниц = потолок ~3-5K clicks/month в лучшем случае**. Чтобы получать 50K clicks/mo, индексируемая поверхность должна быть 3000-5000 страниц.
+
+Этот фикс — про объём, не про техническую починку. Решается через Phase 1 (content scale): больше books published → больше editions/authors/genres страниц + добавление hub pages (themed lists, curated collections).
+
+85 Soft 404 — это страницы в индексируемой поверхности которые Google считает пустыми. Заполнить через SEO backfill или вернуть 410 = быстрый bump indexed count.
+
+### 2. Все ranking запросы на позиции 60+
+
+`james joyce books` pos 75, `mary shelley books` pos 72, `complete novels of james joyce` pos 89. Это страница 8-9 Google. Google знает что страницы есть, но даёт им последний приоритет. Причины:
+- Слабый authority сайта (новый домен, мало беклинков)
+- Контент тоньше чем у конкурентов (Goodreads, OpenLibrary, Project Gutenberg) на тех же страницах
+- Внутренняя перелинковка не дает достаточно signal
+
+**Фикс:**
+- Усилить author overview pages: bio 300+ слов, список всех editions с descriptions, links to themes/genres, related authors. Author page для `james joyce` должен иметь больше депости чем стандартный книжный сайт.
+- Internal linking: с каждой book editions ссылки на: author, genre, related editions, theme/topic. Сейчас 11 indexable страниц без outgoing links — починить.
+- Беклинки в дальней перспективе.
+
+### 3. ~~Internal broken links~~ — LEGACY DEBT, не текущий баг
+
+SEO было сломано ~3 месяца назад (до фиксов). Ahrefs "New" колонка означает "URL впервые обнаружен в этом crawl", не "URL свежее сломался". Краулер просто переваривает бэклог старых URL. Health Score 87/100 подтверждает что текущее состояние сайта здоровое. Никаких действий — ждём пока Google и Ahrefs догонят реальность, числа естественно спадут.
+
+### 4. ~~GA4 anomaly 23 April~~ — RESOLVED
+
+Был добавлен в каталог, гнавший боты под видом Direct. После удаления из каталога — метрики нормализовались. Никаких действий не требуется. Урок на будущее: high Direct + 1s engagement = почти всегда бот-источник, проверять каталоги/листинги где сайт упомянут.
+
+## High-impact opportunities (P1)
+
+### 1. Hub pages под информационный intent
+
+Сейчас нет страниц-хабов которые ловят long-tail типа "best classic novels for software engineers", "free books about ethics in technology", "russian literature in english". Это контент который сам ранжируется, а потом линкует на book pages — двойной эффект.
+
+Кандидаты под dev/AI engineer аудиторию:
+- "Books every software engineer should read"
+- "Classic novels about AI and ethics"
+- "Short classics you can finish in a weekend"
+- "Free books for English language learners"
+- "Russian classics in English translation"
+- "Free books about war and humanity"
+- "Best free SF/fantasy classics" (public domain)
+
+### 2. SEO backfill приоритизация по слабым страницам
+
+SEO backfill уже есть в инфраструктуре. Использовать его в первую очередь на:
+- Soft 404 страницы (85) — заполнить контентом или 410
+- Crawled-not-indexed страницы — добавить уникальное value
+- Authors с одной книгой (тонкие)
+- Genres с малым числом editions
+
+### 3. Soft 404 фикс
+
+85 страниц Google считает пустыми. Скорее всего: authors без bio, genres без description, editions без relevance/themes. Эти страницы либо заполнить через SEO backfill, либо если они объективно не нужны — 410 Gone (не 404, а явное "удалено").
+
+## Низкий приоритет (P2)
+
+- 11 Duplicate без canonical — проверить, скорее всего work/edition pair
+- 11 Low word count — заполнить через SEO backfill
+- 11 Meta description too long — обрезать в template (160 chars max)
+- 7 H1 missing — проверить и поправить шаблон страницы
+- 11 Pages without outgoing links — добавить internal links
+
+## Unknowns to investigate
+
+1. **23 April anomaly** — что произошло с USA трафиком и clean-code страницей.
+2. **`?direct=1` URLs в индексе** — query string остался без canonical к чистому URL. Проверить что rel="canonical" на них стоит на чистый URL.
+3. **Sitemap covers only 391 of 1200 indexable URLs** — почему не все индексируемые в sitemap? Возможно SSG-generated sitemap не подхватывает authors/genres.
+4. **65 "Duplicate, Google chose different canonical than user"** — у нас canonical issue, надо посмотреть конкретные URL.
+5. **Engagement time 11s avg в GA4** — это сильно искажено. Проверить что engagement events настроены (scroll, page_view с min duration).
+
+## Метрики для трекинга (еженедельно)
+
+- GSC: total clicks (target growth), impressions, indexed pages count, "Crawled-not-indexed" count
+- GA4: Organic Search sessions, Engagement rate, top landing pages by organic
+- Ahrefs: Health Score, Errors count (особенно 404 count), Referring domains
+- Brand search: `textstack` impressions trend в GSC
diff --git a/docs/seo/roadmap-50k.md b/docs/seo/roadmap-50k.md
new file mode 100644
index 00000000..f1401a06
--- /dev/null
+++ b/docs/seo/roadmap-50k.md
@@ -0,0 +1,179 @@
+# SEO Roadmap → 50K Google clicks/month
+
+Стартовая точка (2026-05-14): 15 кликов/месяц. Цель: 50,000 кликов/месяц.
+Множитель ~3,300×. Реалистичный таймлайн: 18-24 месяца при последовательном исполнении.
+
+Связанный документ: [audit-2026-05-14.md](./audit-2026-05-14.md).
+
+## Принципы
+
+1. **Контент = главный драйвер**, не беклинки. Беклинки усиливают то что уже хорошо ранжируется; они не вытащат тонкие страницы.
+2. **Метадата only** — chapter pages остаются noindex (избегаем duplicate с Gutenberg).
+3. **Long-tail прежде head terms** — `james joyce books` имеет тысячу конкурентов; `themes in joyce dubliners` имеет десятки.
+4. **Hub pages > много отдельных страниц** — одна хорошая hub-страница ранжируется лучше чем 50 тонких author pages.
+5. **Track impressions раньше чем clicks** — impressions это leading indicator, растёт за 2-3 мес до того как клики прорастут.
+6. **Не покупать беклинки никогда** — в нише free books Penguin особенно агрессивен.
+
+## Траектория
+
+| Месяц | Indexed pages | Impressions/mo | Clicks/mo |
+|-------|---------------|----------------|-----------|
+| Now (2026-05) | 327 | ~360 | ~15 |
+| +3 (2026-08) | 800 | 2K | 100 |
+| +6 (2026-11) | 1,500 | 8K | 500 |
+| +12 (2027-05) | 2,500 | 50K | 3,500 |
+| +18 (2027-11) | 3,500 | 200K | 15,000 |
+| +24 (2028-05) | 4,500 | 500K | 50,000 |
+
+Это **optimistic если исполнять последовательно**. Если выпадет 2-3 месяца — добавить +6 мес к каждой вехе.
+
+---
+
+## Phase 0 — Текущие реальные блокеры (Now → +2 weeks)
+
+Технический фундамент в порядке: sitemap — корректный index с 4 sub-sitemaps (books/authors/genres/pages), 391 URLs всего, 84% indexed coverage. Health Score 87/100. Большинство Ahrefs/GSC ошибок — легаси-долг от старых поломок SEO (~3 мес назад), краулеры переваривают бэклог. Это выгорит само.
+
+Реально стоит сделать:
+
+- [x] ~~Разобраться с 23 April anomaly~~ — RESOLVED: каталог с ботами был удалён, метрики нормализовались.
+- [x] ~~Sitemap coverage~~ — sitemap уже корректный index с 4 sub-sitemaps. Не требует фикса.
+- [ ] **85 Soft 404 → заполнить или 410** — Google прямо сейчас считает эти страницы пустыми. Экспортнуть из GSC, классифицировать: (a) тонкая authors/genres → SEO backfill; (b) объективно удалённые → 410 Gone. Быстрый win, добавит ~50+ страниц в индекс.
+- [ ] **GA4 engagement events** — добавить scroll и engagement события чтобы avg engagement time стал достоверным. Сейчас 11s искажает business reporting (не SEO напрямую).
+
+**Что НЕ делаем (легаси-долг, выгорит сам):**
+- ~~SlugHistory + 301 для роста 404s~~ — Ahrefs "new" = переваривание бэклога, не свежие поломки.
+- ~~65 duplicate canonical mismatch~~ — скорее всего старые URLs из периода поломок.
+- ~~171 pages with broken internal links~~ — те же legacy URLs.
+- ~~`?direct=1` canonical audit~~ — старые URLs, новые ссылки уже корректны.
+
+После Phase 0 главный рычаг это **Phase 1 (content scale)** — увеличить индексируемую поверхность с 391 до 3000-5000 страниц через publication + hub pages. Это и есть реальная работа на пути к 50K clicks/mo.
+
+## Phase 1 — Content base (Month 1-3)
+
+Цель: 800 indexed pages, ~100 clicks/mo. Это про объём + качество существующих метадата-страниц.
+
+- [ ] **Auto-publish 500+ books** через существующий pipeline. Приоритет:
+ - Public domain классика которая широко искомая (Project Gutenberg top 100)
+ - Books релевантные для dev/AI engineer аудитории
+ - Short classics (легко завершить, хорошие session metrics)
+- [ ] **SEO backfill quality pass** на ВСЕ existing editions:
+ - Description (200+ слов, unique angle, не generic)
+ - Relevance (почему стоит читать сейчас)
+ - Themes (3-5 темы с расшифровкой)
+ - FAQs (5 вопросов с ответами 50+ слов каждый)
+ - SeoTitle + SeoDescription (60/160 chars, intent-matched)
+- [ ] **Authors pages** — для всех authors с editions:
+ - Bio 300+ слов
+ - Список всех editions с teaser descriptions
+ - "Related authors" блок (3-5 ссылок)
+ - Schema.org Person + sameAs (Wikipedia, Wikidata)
+- [ ] **Genres pages** — все genres:
+ - Description 300+ слов о жанре
+ - Top editions с teaser
+ - Related genres
+ - Sub-themes если есть
+- [ ] **Internal linking pass** — каждый edition page должен иметь:
+ - Author link
+ - Genre link
+ - 3 "Related editions" (same author OR same genre OR same theme)
+ - Breadcrumb (уже есть, проверить)
+- [ ] **Fix 171 pages with broken internal links** — найти source pages, удалить или обновить ссылки.
+
+## Phase 2 — Hub pages для информационного intent (Month 3-6)
+
+Цель: 1,500 indexed pages, ~500 clicks/mo. Это про новый тип трафика — информационный, не транзакционный.
+
+Hub pages сами ранжируются на long-tail и линкуют на book pages. Это даёт двойной эффект: hub получает clicks, book pages получают internal links.
+
+**Кандидаты на hub pages под dev/AI engineer аудиторию:**
+
+- [ ] Books every software engineer should read (curated 20-30)
+- [ ] Classic novels about AI, ethics, and technology (10-15)
+- [ ] Short classics you can finish in a weekend (15-20)
+- [ ] Free books for English language learners (graded by level)
+- [ ] Russian classics in English translation (15-20)
+- [ ] Best free public domain SF and fantasy (20-30)
+- [ ] Books about systems thinking and complexity (10-15)
+- [ ] Classic philosophy free to read online (15-20)
+- [ ] Free books about war and humanity (10-15)
+- [ ] Books that shaped modern thought (curated essays)
+
+Каждая hub page = 800-1200 слов оригинального контента + список книг с teaser + internal links на каждую. Это контент типа "best of" listicle который Google и любит, и который часто получает беклинки естественно.
+
+**Технически**: либо как часть React app (`/en/lists/{slug}`), либо как admin-managed entity новой entity `Collection` с editions M2M. ADR нужен.
+
+## Phase 3 — Authority и беклинки (Month 6-12)
+
+Цель: 2,500 indexed pages, ~3,500 clicks/mo. Это про чтобы поднять existing pages на странице 1-2 Google.
+
+К этому моменту контент-машина работает; теперь добавляем authority signals.
+
+**Outreach каналы (ranked by ROI):**
+
+- [ ] **Hacker News пост** про техническую сторону TextStack (SSG, vocabulary SRS, Edge TTS WebSocket, extraction pipeline). Аудитория HN читает такое; даёт dofollow ссылку + долгий referral хвост.
+- [ ] **Show HN запуск** — отдельно, когда будут метрики и история.
+- [ ] **HARO / Qwoted / Help A B2B Writer** — отвечать на запросы по темам чтения, образования, language learning, productivity. Получать цитаты в крупных изданиях. ~1 hour/неделю, 2-3 backlink/мес ожидаемо.
+- [ ] **Dev.to + Hashnode posts** — длинные технические статьи про building TextStack. Каждый пост = link to textstack.app. 5-10 постов даст 5-10 dofollow.
+- [ ] **Reddit organic** — r/books, r/printSF, r/learnprogramming, r/languagelearning. Не self-promo, а полезные комменты с упоминанием когда уместно. 1-2 hour/неделю.
+- [ ] **Product Hunt запуск** — когда будут полные feature set и story. Один-два дня большого трафика + долгий PH-backlink.
+- [ ] **Guest posts на dev-блогах** — про deep reading, vocabulary, language learning. С естественной ссылкой на TextStack.
+- [ ] **Listicles от себя** — "Best free reading apps for developers 2026" на vasyl.blog и Dev.to. Цитируют и линкуют другие блоги.
+
+**Чего НЕ делать:**
+- Покупать беклинки (Penguin penalty)
+- Mass outreach с шаблонами (не работает)
+- PBNs (Private Blog Networks)
+- Comment spam
+
+## Phase 4 — Scale (Month 12-24)
+
+Цель: 4,500 indexed pages, 50K clicks/mo.
+
+К этому моменту первые 3 фазы должны давать стабильный organic growth. Это фаза масштабирования того что работает.
+
+- [ ] **Chapter-by-chapter summaries** — UNIQUE контент для классики, которой мало в хорошем виде. Один edition = book overview + chapter summaries (каждый ~500 слов с критическим анализом). Это даёт unique value vs Goodreads/SparkNotes и оправдывает снятие noindex для chapter summary pages (но НЕ для самого текста).
+- [ ] **Study guides** — для книг которые часто читают в школах/universities. Themes, characters, motifs, key quotes (короткие).
+- [ ] **Multi-language** — добавить ru, uk если есть ресурс. Каждый язык = новая поверхность с минимумом конкурентов в нашей нише.
+- [ ] **Audio TTS landing pages** — `Listen to {Book Title} in English (free)` — отдельный intent, мало конкурентов.
+- [ ] **Vocabulary by book pages** — `Words from {Book Title}` — поверхность которой никто не покрывает.
+
+## Метрики и cadence
+
+**Еженедельно** (10 мин):
+- GSC: total clicks, impressions, indexed pages count, "Crawled-not-indexed" count, average position
+- Ahrefs: Health Score, Errors count (особенно 404s), Referring domains delta
+- GA4: Organic Search sessions, engagement rate, top landing pages
+
+**Раз в 2 недели** (30 мин):
+- Top 20 queries в GSC — есть ли движение position?
+- Pages со средней позицией 11-20 — кандидаты на content refresh (добавить депости, internal links)
+- CTR < 2% на impressions > 50 — переписывать title/description
+
+**Раз в месяц** (1-2 часа):
+- Аудит auto-publish quality (sample 10 newly published, проверить descriptions/themes)
+- Hub pages performance — какие ранжируются, какие не работают
+- Backlinks audit — что появилось, какие из них качественные
+
+**Quarterly** (полдня):
+- Полный Ahrefs audit re-run
+- GSC review всех "Crawled-not-indexed" и Soft 404
+- Competitor analysis (Goodreads, Standard Ebooks, OpenLibrary, Z-library) — что у них ранжируется по target queries
+- Strategy review: что работает быстрее ожиданий, что отстаёт
+
+## Социальные сети — отдельно
+
+Соцсети **не влияют на rankings напрямую**, но дают brand search (Google это засчитывает) и прямой трафик.
+
+- **Twitter** (@Rexetdeus): build in public, weekly metrics threads, feature launches, технические треды. Это работает для dev-аудитории. Уже идёт через `docs/marketing/x-routine/`.
+- **Dev.to / Hashnode**: длинные технические статьи. Покрывается в Phase 3.
+- **YouTube**: demos vocabulary SRS, reader UX, технические разборы. Низкий приоритет — нужно время на production. Откладываем до Phase 4.
+- **BookTok / Bookstagram**: другая аудитория, другой контент-стиль. **Не делать** в обозримой перспективе — распыление ресурсов.
+
+## Что не делать
+
+- Не клепать AI-generated thin content без редактуры — Helpful Content Update убьёт.
+- Не таргетировать коммерческие фразы (`buy ebook`) — intent не совпадает с бесплатной библиотекой.
+- Не пытаться ранжироваться head terms (`free books`) — у Gutenberg DR 88, мы не пройдём.
+- Не делать doorway pages под каждый ключевик — Google ловит давно.
+- Не оптимизировать chapter pages — они noindex, и это правильно.
+- Не пытаться "побыстрее" — Google sandbox для нового домена и низкий authority это органически 12+ месяцев работы.
diff --git a/hackernews-launch-post.md b/hackernews-launch-post.md
new file mode 100644
index 00000000..204847c1
--- /dev/null
+++ b/hackernews-launch-post.md
@@ -0,0 +1,174 @@
+# Show HN Launch Post — TextStack
+
+Submit at: https://news.ycombinator.com/submit
+
+Positioning anchor: README's hero — "Deep-reading tool for developers learning AI engineering. Tap an unknown term → context-aware explanation inline. A modern replacement for Kindle Word Wise and LingQ — built for technical books."
+
+Origin article (cite as "Why I built it" if asked): https://vasyl.blog/2026/04/21/i-quit-designing-data-intensive-applications-ddia-three-times-heres-what-i-build-on-the-fourth-try/
+
+---
+
+## URL field
+
+```
+https://textstack.app
+```
+
+## Title (pick one)
+
+**Recommended (personal hook — strongest for HN):**
+```
+Show HN: I quit DDIA three times – built a reader that explains terms inline
+```
+
+Alternatives:
+```
+Show HN: TextStack – Kindle Word Wise for technical books, but LLM-powered
+Show HN: TextStack – Tap a term in a tech book, get a context-aware explanation
+Show HN: A reader that knows "attention" means ML in an ML book and biology in a bio book
+```
+
+Title rules HN actually enforces:
+- No "the best", no "amazing", no marketing fluff
+- Lead with a specific claim, not a category
+- Under 80 characters
+- "Show HN:" prefix is required
+
+The recommended title works because (a) it's a personal admission HN respects, (b) DDIA is iconic enough that 80%+ of HN readers will recognize it instantly, and (c) "explains terms inline" is concrete.
+
+---
+
+## First comment (post immediately after submitting)
+
+Hi HN,
+
+I quit *Designing Data-Intensive Applications* three times. Not because it was hard — I understood most of what was on the page. The problem was the rest: unfamiliar terms that broke the flow. Eventual consistency. Attention mechanism. B-tree. Writing each one down to look up later works until you have 40 of them and you've already lost the thread.
+
+Summarizing books away defeats the point. The only way to actually internalize something like DDIA or the Karpathy nanoGPT papers is to read them — but the friction has to go.
+
+So TextStack works like this:
+
+- Tap a term you don't know → 2-3 sentence LLM-powered explanation tied to the book's domain
+- Tap "attention" in an ML textbook → ML meaning. Tap "attention" in a psychology book → cognitive meaning. Same word, different domain, different answer.
+- Terms you didn't recognize go into a **capped weekly SRS queue** — no infinite backlog, no guilt spiral. Common words and the top 15K English words are filtered out, so only technical vocabulary surfaces.
+
+The thing this replaces is Kindle Word Wise (static dictionary, 2014, falls over on technical terms) and LingQ (built for natural languages, not technical ones). I tried both before building this.
+
+Stack:
+- ASP.NET Core 10 (Minimal APIs, modular monolith) + PostgreSQL 16 + EF Core
+- React 19 (web) + React Native / Expo 55 (mobile, Android live, iOS in TestFlight)
+- OpenAI gpt-5-mini for explanations and translation; local Ollama qwen3:8b for SRS distractors
+- Edge TTS over WebSocket for pronunciation (no API key, 200+ voices)
+- Postgres FTS for search (Meilisearch swappable behind an interface)
+- Puppeteer SSG for SEO pages — bot-detecting nginx routes crawlers to prerendered HTML, humans get the SPA
+- OpenTelemetry → Aspire dashboard for traces
+- Single docker compose, deploys via Cloudflare Tunnel
+
+Honest limitations:
+- Curated technical corpus is small right now (~15-20 hand-picked titles plus 1500+ classics). Personal uploads (EPUB/PDF/FB2) are unlimited.
+- Explanation latency is ~1-2s on first call (cached after).
+- iOS app is TestFlight-only — App Store review pending. Android is live on Google Play.
+- Source-available, not OSI open source — BUSL-1.1, auto-converts to Apache-2.0 in 2030. Self-hosting for personal/internal use is fully allowed; reselling as a hosted service is not.
+
+Try it without signing up:
+https://textstack.app — sample chapters open without an account. Tap any unfamiliar term to see the explanation flow.
+
+Things I'd love feedback on:
+1. The capped SRS queue is a strong opinion — most SRS tools push infinite Anki-style backlogs and people drown. Does the cap make sense or do you want to override it?
+2. Is "tap a term" the right interaction on desktop, or should there be a hover-to-preview alternative?
+3. Curated corpus: which technical books would you want most? I'm prioritizing DDIA, Karpathy/Stanford ML papers, type theory, distributed systems classics. What am I missing?
+
+Background article on the "why" if you want the longer version: https://vasyl.blog/2026/04/21/i-quit-designing-data-intensive-applications-ddia-three-times-heres-what-i-build-on-the-fourth-try/
+
+— Vasyl (https://github.com/mrviduus, @Rexetdeus)
+
+---
+
+## When to post
+
+**Best time for Show HN (US-centric audience):**
+- Tuesday, Wednesday, or Thursday
+- 8:00–10:00 AM Eastern Time (your local time, since you're in Toronto)
+- NOT Monday morning (overflow from weekend), NOT Friday (lower attention)
+
+**Why timing matters:** Show HN posts need ~3-5 upvotes in the first 30-60 minutes to escape /newest and reach /show. If you post at 3 AM ET, it'll be buried before US devs wake up.
+
+---
+
+## Pre-flight checklist
+
+Before hitting submit, verify:
+
+- [ ] textstack.app loads on first try (warm the cache)
+- [ ] The "tap a term, get explanation" flow works on the chapter you'll link to
+- [ ] No console errors on the demo page
+- [ ] Sign-up via email/Google works end-to-end (test in incognito)
+- [ ] Server has headroom — HN front page = 5-50K visitors in a few hours
+- [ ] OpenAI billing has budget — explanations cost money per call, traffic spike could trigger a rate limit or 429
+- [ ] Rate limits are sane (you have nginx zones for `/api`, `/uploads`, `/translate`)
+- [ ] Status page or graceful fallback if API goes down
+- [ ] HN account has karma > 0 and is at least a few days old (new accounts get filtered)
+
+**OpenAI cost note**: at the worst case of 50K HN visitors × 5 explanations each × $0.0001/call, that's ~$25. Realistic case (5% try the demo, 3 explanations each) is ~$0.75. Fine, but watch the dashboard.
+
+---
+
+## After posting
+
+**First hour is critical.** Do these in order:
+
+1. Drop the first comment (the body above) within 60 seconds of submitting.
+2. Pin the submission tab open. Refresh `news.ycombinator.com/show` after 15 min — your post should appear there.
+3. Reply to every comment within the first 2 hours. HN ranks posts partially on author engagement.
+4. Don't ask friends to upvote — HN detects vote rings and will flag the post.
+5. Do post the link in your own networks (Twitter @Rexetdeus, LinkedIn, vasyl.blog) — organic traffic is fine.
+
+**Common HN questions — prepared answers:**
+
+*"How is this different from Readwise / LingQ / Kindle Vocabulary Builder?"*
+> Readwise focuses on highlight management — surfacing what you already marked, not explaining what you didn't understand. LingQ is built for natural-language learning, not technical vocabulary; it doesn't know what "attention mechanism" means in context. Kindle Word Wise is a 2014 dictionary lookup — fine for general English, useless for "B-tree" or "monad". TextStack's bet is that LLMs finally make context-aware explanations cheap enough to do per-term, per-book.
+
+*"Why BUSL and not just MIT?"*
+> Because I want one paying customer by October. BUSL lets me self-host, lets you fork and modify, but blocks competitors from launching a hosted clone. In 2030 it auto-converts to Apache-2.0. If you don't agree with the license, the source is still on GitHub and you can read it.
+
+*"Why ASP.NET? Isn't C# weird for this?"*
+> It's what I'm fastest in. .NET 10 + EF Core + a modular monolith with central package versioning makes the codebase cheap to maintain solo. The mobile and web layers are React, which is most of the user-facing complexity anyway.
+
+*"Have you tried [tool X]?"*
+> Yes — I tried Kindle Word Wise (limited dictionary, no SRS), Anki + manual mining (the friction that broke me on DDIA), LingQ (wrong domain), and Readwise (different problem). The thing I couldn't find was "tap an unfamiliar term in a technical book and get a context-aware explanation".
+
+*"What's the cost to run this for me self-hosted?"*
+> Postgres + .NET API + Worker fits in a $10-20/mo VPS for a single-user setup. The biggest variable cost is OpenAI API for the explanations — figure $0.10-0.50/month per active reader. Ollama for distractors is free and local.
+
+*"Will you add [feature]?"*
+> The 6-month roadmap is in the README. Next up is iOS App Store, capped weekly SRS UX polish, and curating 15-20 AI-engineering titles (DDIA, ML papers). Beyond that, no commitments.
+
+*"Is the explanation accurate? LLMs hallucinate."*
+> They do. Right now I'm relying on gpt-5-mini being good enough that the 2-3 sentence explanation is right >95% of the time on technical terms. Users can flag bad explanations; I haven't built that loop yet. If you spot a hallucination on the demo, tell me — that's a real research gap.
+
+---
+
+## Backlinks angle (your secondary goal)
+
+A successful Show HN gives you:
+- 1 dofollow link from `news.ycombinator.com` (high-authority domain)
+- Often 5-20 secondary mentions from blogs and aggregators that scrape the HN front page (Hacker News Daily, hckrnews.com, indie newsletters)
+- Twitter / LinkedIn pickups from HN regulars
+- Often Lobste.rs cross-post (another high-authority dofollow)
+
+A flopped Show HN gives you:
+- 1 nofollow link, no traffic, no backlinks
+- And you can't repost the same title for 30 days
+
+Translation: pick the right time, warm the demo, prepare the canned answers above. You only get one shot with this title.
+
+---
+
+## If it flops
+
+Show HN posts that don't catch fire in the first 90 minutes are usually dead. If that happens:
+
+- Don't repost the same title within 30 days — HN penalizes reposts.
+- Wait 2-3 weeks, then submit a regular HN post (not "Show HN") with a different angle. Your DDIA blog article itself is HN-worthy as a standalone submission — title it something like *"I quit DDIA three times — here's what finally worked"* and link to vasyl.blog. The link to TextStack in the article does the work.
+- Run Product Hunt launch first, then come back to HN with "We launched on PH last week, here's what we learned" — that's a fresh angle that usually performs.
+- Lobste.rs is a smaller but higher-quality audience — needs an invite, but if you can get one, the developer-tools angle of TextStack will land well there.
diff --git a/infra/scripts/__pycache__/pdf-cleanup-gate.cpython-314.pyc b/infra/scripts/__pycache__/pdf-cleanup-gate.cpython-314.pyc
new file mode 100644
index 00000000..8645c66f
Binary files /dev/null and b/infra/scripts/__pycache__/pdf-cleanup-gate.cpython-314.pyc differ
diff --git a/lu421jrdb6.tmp b/lu421jrdb6.tmp
new file mode 100644
index 00000000..c34dd20f
Binary files /dev/null and b/lu421jrdb6.tmp differ
diff --git a/lu47152jl.tmp b/lu47152jl.tmp
new file mode 100644
index 00000000..6de3b59f
Binary files /dev/null and b/lu47152jl.tmp differ
diff --git a/lu971jrdrp.tmp b/lu971jrdrp.tmp
new file mode 100644
index 00000000..f8c0d5fa
Binary files /dev/null and b/lu971jrdrp.tmp differ
diff --git a/publish-day-cheatsheet.md b/publish-day-cheatsheet.md
new file mode 100644
index 00000000..337e2198
--- /dev/null
+++ b/publish-day-cheatsheet.md
@@ -0,0 +1,131 @@
+# Publish day cheat sheet — Monday, May 11, 2026
+
+**Target publish time:** 08:30–08:35 ET (12:30–12:35 UTC)
+
+**Pre-flight check (do tonight, Sunday):**
+
+- [x] MCQ screenshot at `docs/marketing/srs-mcq-card.png` (extracted from your recording)
+- [x] MCQ walkthrough gif at `docs/marketing/srs-mcq-demo.gif` (extracted from your recording, 37s, 2.0 MB)
+- [ ] **Commit and push** the two new media files in `docs/marketing/` to `main` — required for the GitHub raw URLs in the article to resolve when Dev.to fetches them
+- [ ] Claude Code SSH-prompt run, prod stats collected
+- [ ] Final read-through of `devto-gemma4-article.md` done — any factual nits caught
+- [ ] Phone alarm set for 08:00 ET
+
+---
+
+## Sunday evening (tonight) — 20 min
+
+| When | Step |
+|---|---|
+| Now | `git add docs/marketing/srs-mcq-card.png docs/marketing/srs-mcq-demo.gif && git commit -m "docs: add MCQ vocab demo media for Gemma 4 challenge post" && git push` — needed before Dev.to can fetch the raw URLs |
+| Now | Run the Claude Code SSH prompt (`claude-code-prod-stats-prompt.md`), save the report numbers |
+| Tonight | Open the dev.to draft (see "Schedule the post tonight" below). Paste the article body — media is referenced by raw GitHub URL so no manual upload needed. Schedule for `2026-05-11 12:30 UTC` |
+
+### Schedule the post tonight (recommended)
+
+1. Open https://dev.to/new in a browser where you're logged in
+2. Title: `I shipped local LLM features two months ago. Production never ran them once.`
+3. Tags: `devchallenge` `gemmachallenge` `gemma` `ollama`
+4. Cover image: click "Add a cover image" → "Generate image" → paste this prompt:
+
+ ```
+ Flat minimalist illustration: a server rack labeled "ollama" in the foreground,
+ its model slot drawn as an empty glass cylinder. On the right, a fresh model
+ container labeled "gemma4:e4b" sliding in. Faint code-trace lines glowing
+ underneath in soft teal and purple. Wide banner aspect ratio, no people,
+ no faces, dev.to-friendly clean style.
+ ```
+
+5. Paste body from `devto-gemma4-article.md` (the markdown block between the triple-backticks under `## Article body (paste into Dev.to editor)`). Both image references already point to `raw.githubusercontent.com/mrviduus/textstack/main/docs/marketing/...` — Dev.to fetches them server-side at publish time
+6. If you got a real distractor count from the SSH prompt, edit the "What's next" paragraph to mention it
+7. Click ⋯ "More options" → set **Schedule for**: `2026-05-11 12:30 UTC` (= 08:30 ET)
+8. Click **Schedule**
+9. Verify the draft is now scheduled (status should read "Scheduled" not "Draft")
+10. Open the post-preview URL once to confirm both images render — if either fails, the most likely cause is the commit not being pushed yet (`git status` to verify)
+
+If scheduling fails for any reason, fall back to: leave the draft saved, set a phone alarm for 08:00 ET, publish manually.
+
+---
+
+## Monday morning — minute-by-minute
+
+| Time (ET) | Step | Reference |
+|---|---|---|
+| 08:00 | Wake, coffee, open laptop. Open: dev.to/dashboard, GitHub repo, Twitter, the social pack file | — |
+| 08:25 | Verify scheduled post exists in dashboard. If not — publish manually NOW | — |
+| 08:30 | Post auto-publishes. Copy the resulting URL. Refresh Dev.to to confirm it's live at https://dev.to/t/gemmachallenge/latest | — |
+| 08:31 | React to your own post (👍 + 🦄 + 🔖) | DEV allows this |
+| 08:32 | Open `social-media-pack.md`, find the URL placeholder in section 1 (Twitter), replace `[POST URL]` with the live URL | section 1 |
+| 08:33–08:38 | Post the 5-tweet thread from `@Rexetdeus` | — |
+| 08:38 | Pin the thread to your profile | — |
+| 08:40 | Open `r/LocalLLaMA`, paste the post body from `social-media-pack.md` section 2. Submit | section 2 |
+| 08:50 | r/selfhosted post (10-min gap to avoid cross-post detector) | section 3 |
+| 09:00 | r/dotnet post | section 4 |
+| 09:15 | HackerNews Show HN submission | section 5 |
+| 09:30 | LinkedIn post | section 6 |
+| 09:45 | Comment on the Gemma 4 Challenge launch post (Jess Lee thread) | section 7 |
+| 10:00 | DM 5–10 friends from the personal-network template | section 8 |
+
+---
+
+## First 4 hours — engagement watch
+
+| When | What |
+|---|---|
+| Continuous, every 15 min | Refresh dev.to post. Reply to every new comment within 10 min. Use templates from `comment-response-templates.md` if applicable |
+| Continuous | Refresh r/LocalLLaMA + r/selfhosted + r/dotnet posts. Reply to every new comment within 15 min |
+| Continuous | Refresh HN post. Reply within 10 min |
+| 12:00 ET (lunch break in US East) | Check reaction count on the dev.to post. Compare against current `#gemmachallenge` Build leaderboard |
+| 14:00 ET | Same check. If we're not yet in top-3 Build, do a second wave: ask 3 more personal-network contacts |
+
+---
+
+## End of day — measure + plan
+
+By 18:00 ET, expect:
+
+- Dev.to post: **20–40 reactions** (target: top 3 in `#gemmachallenge` Build)
+- Twitter thread: 100+ impressions, 5+ likes (this is small but normal for tech content)
+- Reddit total karma: 50–200 across all 3 subs (depends heavily on subreddit reception)
+- HN: either dead or trending (binary outcome — front page or invisible by 14:00 ET)
+- GitHub stars: +5 to +20 (delta from where you start the day)
+
+Note your end-of-day numbers somewhere. They become the Day 0 baseline for the rest of the challenge.
+
+---
+
+## Daily routine until May 24 (deadline)
+
+| Time | Daily task | Why |
+|---|---|---|
+| Morning | Refresh dev.to post, reply to overnight comments | Algorithm rewards reply velocity |
+| Midday | Check `#gemmachallenge` Build leaderboard, note any new strong entries | Strategic awareness |
+| Evening | If something worth riffing on appeared in the field, drop a substantive comment on it | Cross-pollinates readers |
+| Daily | Note GitHub star delta | Tracks the secondary goal |
+
+---
+
+## Failure modes to avoid
+
+- **Don't shadow-publish** — never publish at 02:00 ET to "get it out". Wasted boost window.
+- **Don't reply with "thanks"** — reply with substance or skip.
+- **Don't argue with bad-faith comments** — ignore. Real engagement comes from substantive replies, not flame wars.
+- **Don't repost the same blurb across subs** — Reddit cross-post detector flags + each sub has its own tone (different bodies in `social-media-pack.md` for that reason).
+- **Don't ask for stars/upvotes in comments** — only in the original post body or DMs. Asking in comments comes across as desperate.
+- **Don't edit the post heavily after publish** — minor typo fixes OK; don't restructure or add new sections, you'll lose the engagement signal.
+- **Don't promote Day 2+** — DEV's algorithm boost window is 24–48h. Don't post the same Reddit links again on Tuesday.
+
+---
+
+## Quick links (have these in tabs Monday morning)
+
+- Dev.to dashboard: https://dev.to/dashboard
+- The article (will be under your username): `https://dev.to/[your-username]`
+- Challenge tag: https://dev.to/t/gemmachallenge/latest
+- Build template URL (in case scheduled post failed and you need a fresh draft): https://dev.to/new?prefill=---%0Atitle%3A%20%0Apublished%3A%20%0Atags%3A%20devchallenge%2C%20gemmachallenge%2C%20gemma%0A---
+- Repo: https://github.com/mrviduus/textstack
+- Live: https://textstack.app
+- r/LocalLLaMA: https://www.reddit.com/r/LocalLLaMA/submit
+- r/selfhosted: https://www.reddit.com/r/selfhosted/submit
+- r/dotnet: https://www.reddit.com/r/dotnet/submit
+- HN submit: https://news.ycombinator.com/submit
diff --git a/release-notes-v0.1.0.md b/release-notes-v0.1.0.md
new file mode 100644
index 00000000..1a612919
--- /dev/null
+++ b/release-notes-v0.1.0.md
@@ -0,0 +1,128 @@
+# v0.1.0 — First AGPL-3.0 release
+
+First tagged release of TextStack as a public open-source project under
+**GNU Affero General Public License v3.0**.
+
+## Why this release
+
+This release marks two milestones:
+
+1. **TextStack is now real open-source software.** Earlier development
+ happened under a source-available license (BUSL-1.1). All code in v0.1.0
+ and beyond is AGPL-3.0 — OSI-approved, listed in awesome-selfhosted
+ eligibility queue, and dual-licenseable for commercial customers.
+2. **The product is feature-complete enough to use daily.** Reader, capped
+ weekly SRS, vocabulary builder, reading stats, EPUB/PDF/FB2 uploads,
+ offline mode, mobile apps — all working. See full changelog below for
+ the granular history.
+
+## Highlights
+
+### Reader — context-aware explanations
+- Tap a technical term, get a 2-3 sentence LLM-powered explanation tied to
+ the book's domain (powered by OpenAI gpt-5-mini, swappable via
+ `ILlmService`).
+- Tap "attention" in an ML book → ML meaning. Tap it in a psychology book →
+ cognitive meaning. Same word, different domain.
+- Common words and the top 15K English words are filtered out — only
+ technical vocabulary surfaces into your queue.
+
+### Vocabulary SRS — capped weekly queue
+- 5 stages: New → Recognition → Recall → Context cloze → Mastered.
+- LLM-generated distractors and hints (Ollama qwen3:8b, runs locally).
+- Review modes: multiple choice, classic flashcard.
+- **Capped weekly queue** — no infinite Anki-style backlog, no guilt
+ spiral.
+
+### Library
+- 1,500+ curated technical and classic books (starter corpus, self-
+ hostable).
+- Personal uploads: EPUB / PDF / FB2 with auto-parsing, metadata
+ enrichment via local LLM.
+- Reading progress sync, bookmarks, highlights, reading stats.
+
+### Mobile
+- React Native (Expo 55).
+- Android live on Google Play.
+- iOS in TestFlight (App Store review pending).
+- Offline-first, same UX as web.
+
+### Reading stats
+- Heatmap calendar, streaks, daily/weekly goals.
+- 20 achievements across milestone / streak / time / special categories.
+- Session tracking with 30s heartbeat, 3min idle threshold.
+
+### Edge TTS — pronunciation without API keys
+- 200+ voices via direct WebSocket to Microsoft Edge Read Aloud.
+- Two-layer cache (server disk + client IndexedDB).
+- 0.75× to 2.0× speed.
+
+## License
+
+This release is licensed under
+[**GNU Affero General Public License v3.0**](https://github.com/mrviduus/textstack/blob/main/LICENSE).
+
+You may use, modify, and self-host TextStack freely for personal,
+internal, or community purposes. If you modify TextStack and run it as a
+network-accessible service, AGPL-3.0 requires you to publish your
+modifications under the same license.
+
+**Commercial license available** for organizations that need to use
+TextStack without AGPL obligations. Contact: mrviduus@gmail.com.
+
+## Tech stack
+
+- ASP.NET Core 10 (Minimal APIs, modular monolith)
+- PostgreSQL 16 + EF Core (snake_case)
+- React 19 (web), React Native / Expo 55 (mobile)
+- OpenAI gpt-5-mini (explanations, translation)
+- Ollama qwen3:8b (local distractor generation)
+- Edge TTS (WebSocket, no API key)
+- Puppeteer SSG for SEO pages
+- Docker Compose, Cloudflare Tunnel, nginx
+
+## Self-hosting
+
+```bash
+git clone https://github.com/mrviduus/textstack
+cd textstack
+git checkout v0.1.0
+cp .env.example .env # edit with real values
+docker compose up --build
+```
+
+Full instructions in [README](https://github.com/mrviduus/textstack#readme).
+
+## Origin story
+
+I quit *Designing Data-Intensive Applications* three times. Not because it
+was hard — I understood most of what was on the page. The problem was the
+rest: unfamiliar terms that broke the flow. TextStack is the fourth
+attempt — and the one that finally worked.
+
+Full story: [vasyl.blog/2026/04/21/...](https://vasyl.blog/2026/04/21/i-quit-designing-data-intensive-applications-ddia-three-times-heres-what-i-build-on-the-fourth-try/)
+
+## What's next
+
+- Submit to awesome-selfhosted (eligible after 2026-09-04 due to their
+ 4-month seasoning rule)
+- iOS App Store release
+- Capped weekly SRS queue UX polish
+- Curated AI-engineering corpus (DDIA, ML papers, 15-20 titles)
+- Goal: one paying customer by October 2026
+
+## Try it
+
+- **Hosted demo**: https://textstack.app — sample chapters open without
+ signup
+- **Source**: https://github.com/mrviduus/textstack
+- **Mobile**: Google Play (Android), TestFlight (iOS)
+- **Author**: [@Rexetdeus](https://twitter.com/Rexetdeus) /
+ [vasyl.blog](https://vasyl.blog)
+
+---
+
+Star the repo if this resonates. That's the only signal I have right now
+that I'm building the right thing.
+
+— Vasyl
diff --git a/social-media-pack.md b/social-media-pack.md
new file mode 100644
index 00000000..65c864a2
--- /dev/null
+++ b/social-media-pack.md
@@ -0,0 +1,261 @@
+# Social media pack — TextStack Gemma 4 launch
+
+All ready to copy-paste. Order of execution is in the cheat-sheet (`publish-day-cheatsheet.md`). Replace `[POST URL]` with the published Dev.to URL once you have it.
+
+GitHub-star ask is woven naturally into Twitter, Reddit, and LinkedIn. **Not** in HackerNews — HN downvotes star asks. The repo URL still gets visibility there.
+
+---
+
+## 1. Twitter / X — thread (5 tweets)
+
+Post all 5 as a single thread from `@Rexetdeus`. Tweet 1 is the hook, tweet 5 has the CTAs.
+
+**Tweet 1/5**
+
+```
+3 GB used out of 30. The model that runs all my LLM features should be ~13 GB.
+
+I SSH'd in and ran `ollama list`.
+
+Empty.
+
+The container had been running for 60+ days without a single model pulled. Every distractor call had been silently failing.
+
+Post-mortem ↓
+```
+
+**Tweet 2/5**
+
+```
+Production was running a hardcoded random-word fallback the whole time. The user sees distractors, just not LLM-generated ones — so I had no signal it was broken.
+
+The fix took 3 PRs and surfaced four production-only bugs that toy benchmarks would never have caught.
+```
+
+**Tweet 3/5**
+
+```
+Worst offender: floating Docker image tags.
+
+`image: ollama/ollama` froze at 0.22.x the day Docker pulled it. Two months later, upstream Ollama supports Gemma 4. My local "latest" doesn't.
+
+The lie: `docker image ls` shows the cached SHA, not whether the registry has moved.
+```
+
+**Tweet 4/5**
+
+```
+The other surface that bit me: the parser quietly dropped half of Gemma 4's output because it filters multi-word phrases.
+
+qwen3 (the model I'd planned for) emits single tokens by default. Gemma 4 prefers phrases. The parser was correct in spirit, hidden from the model.
+
+Defend at parse, every time.
+```
+
+**Tweet 5/5 (CTAs)**
+
+```
+Full write-up with real numbers (9.6 GB disk, 13 GiB RAM, 2.8s warm inference) on dev.to:
+[POST URL]
+
+The product (open-source, AGPL-3.0, deployed):
+https://github.com/mrviduus/textstack
+
+If the angle resonated, a ⭐ on the repo helps the next person abandoning DDIA find this thing.
+```
+
+---
+
+## 2. Reddit — r/LocalLLaMA
+
+**Title:**
+
+```
+Production was empty for 2 months: lessons from actually shipping local Gemma 4 e4b on a $20 VPS
+```
+
+**Body:**
+
+```
+Two months ago I shipped local-LLM features in TextStack (open-source reader for technical books). Yesterday I checked production RAM and noticed the Ollama container was using 3 GB out of 30. The model should be 13.
+
+`ollama list`: empty. The container had been running 60+ days without a single pull.
+
+Wrote up the full post-mortem of the swap to Gemma 4 e4b — the four production-only bugs that surfaced (floating image tags, cgroup limits guessed for the wrong model, cold-load timeout vs API timeout, parser dropping multi-word output), the real numbers from a single-CPU 30 GB VPS (no GPU), and the cloud-vs-local cost split per task.
+
+Post: [POST URL]
+Repo (AGPL-3.0): https://github.com/mrviduus/textstack
+PRs that wired it in: #232 (model swap) / #233 (parser fix) / #234 (timeouts)
+
+Genuine ask: if anyone here has compared E4B vs E2B on technical-domain prompts, I'd value a sanity check on my "E4B is the smallest model that produces plausible distractors for terms like 'linearizability'" claim. That's the conclusion my testing reached but it's a small sample.
+```
+
+**Subreddit etiquette notes:**
+
+- Don't post to multiple subs within 60 minutes of each other (cross-post detector flag)
+- Don't reply with "Thanks!" — reply with substance or skip
+- If someone says "this is just an ad", reply with one of: a specific technical detail from the post, a screenshot of the bug log, or a "fair, here's the part I think you'd actually find useful: [link to specific section]"
+
+---
+
+## 3. Reddit — r/selfhosted
+
+**Title:**
+
+```
+Open-source reader with local LLM-generated vocabulary cards (Gemma 4 e4b on a $20 VPS, no GPU)
+```
+
+**Body:**
+
+```
+Made an open-source AGPL-3.0 reader for finishing dense English technical books in your native language. Tap any term → context-aware translation that knows the book's domain. Words you don't recognize feed a capped weekly SRS queue with LLM-generated distractor questions.
+
+Two months ago I shipped the local-LLM side and immediately discovered the Ollama container had been silently empty since deploy — production was returning hardcoded random words instead of model output, and the user-facing failure mode was invisible. Just wrote up the post-mortem of swapping in Gemma 4 e4b that finally got the features working.
+
+Stack: docker-compose, .NET 10, Postgres 16, Ollama (Gemma 4 e4b for distractors/hints/explanations + book metadata enrichment), OpenAI gpt-4.1-nano for translation. Everything runs on a single-CPU 30 GB VPS (no GPU). Deploy is a `git pull` and `docker compose up`.
+
+Post: [POST URL]
+Repo: https://github.com/mrviduus/textstack
+Live deploy: https://textstack.app — sample chapters open without signup
+
+The post goes into specifics on what broke when I actually flipped local LLM on (floating image tags, cgroup limits, cold-load timeouts, parser quirks). Hopefully useful to anyone planning to go local-LLM in their self-hosted stack.
+
+Star helps if you'd use a tool like this — repo's open to PRs and the AGPL is real.
+```
+
+---
+
+## 4. Reddit — r/dotnet
+
+**Title:**
+
+```
+ASP.NET Core 10 + Ollama (Gemma 4 e4b) for fire-and-forget LLM jobs — production lessons
+```
+
+**Body:**
+
+```
+Wrote up the integration story of plugging Ollama into an ASP.NET Core 10 worker for vocabulary-related LLM jobs (distractor generation, hint generation, book metadata enrichment).
+
+Architecture is fire-and-forget via `IServiceScopeFactory` — the API endpoint returns immediately and the LLM call happens in the background, with a fallback to a hardcoded random-word picker if Gemma fails or times out. Discovered after two months that the fallback path had been the only path running in production — silent fallback is the worst kind of bug.
+
+Specific .NET-relevant bits in the post:
+- Why I use `IServiceScopeFactory` for the fire-and-forget pattern (avoid disposed scope bugs)
+- Bumping `Ollama:TimeoutSeconds` config from 10s → 30s after seeing 60s cold-load times
+- The C# parser snippet that silently dropped half Gemma's output because of a `!Contains(' ')` filter that worked for qwen3 but not Gemma 4
+
+Post: [POST URL]
+Repo (AGPL-3.0): https://github.com/mrviduus/textstack
+PRs: #232 (model swap), #233 (parser fix), #234 (timeouts)
+
+Project is a self-hostable open-source reader for technical books (textstack.app). Stack: ASP.NET Core 10 / Postgres 16 / React 19 / docker-compose / Cloudflare Tunnel.
+
+Stars on the repo help — it's not a SaaS, just AGPL code I run for myself and anyone else who wants it.
+```
+
+---
+
+## 5. HackerNews — Show HN
+
+**Title:**
+
+```
+Show HN: I rebuilt Kindle Word Wise on local Gemma 4 – production was empty for 2 months
+```
+
+**Body:**
+
+```
+TextStack is an open-source (AGPL-3.0) reader for developers who want to finish dense English technical books in their native language. Tap any term to get a context-aware translation that knows the book's domain ("attention" in an ML chapter gets the ML meaning, not the everyday one). Capped weekly SRS for terms you save.
+
+Local Gemma 4 e4b runs the vocabulary-related LLM jobs (distractors, hints, explanations, book metadata) on a single-CPU 30 GB VPS with no GPU. OpenAI gpt-4.1-nano stays for multilingual translation where local models are weak.
+
+Wrote up the swap to Gemma 4 e4b after discovering the Ollama container had been silently empty in production for 60+ days — the fallback path was a hardcoded random-word picker, indistinguishable to the user. Four production-only bugs surfaced when I flipped it on; the post has the diff for each.
+
+Live: https://textstack.app
+Code: https://github.com/mrviduus/textstack
+Post: [POST URL]
+
+Happy to answer questions on the .NET + Ollama stack, the model selection trade-off (E2B vs E4B vs 31B vs 26B MoE), or the SRS design.
+```
+
+**HN etiquette:**
+
+- Submit between 7–9 AM ET on a weekday (max chance of front-page traction window)
+- Title must start with `Show HN:` and use a hyphen-dash, not em-dash
+- No emoji, no marketing language, no star asks
+- Reply to every comment within 30 min for the first 2 hours — HN's algorithm rewards engagement velocity
+- If someone calls it ad-bait, the substance of the post-mortem story carries the rebuttal
+
+---
+
+## 6. LinkedIn — single post
+
+Less casual than Twitter, more "professional retrospective" tone. Star CTA is appropriate here — LinkedIn devs respond well to "support open source".
+
+**Body:**
+
+```
+Two months of silent production failures, and what swapping to Gemma 4 surfaced about local LLM ops.
+
+I shipped local-LLM features in TextStack (an open-source reader for technical books) two months ago. Last week I noticed the production server was using 3 GB of RAM out of 30. The model that powers all those features should be 13.
+
+I SSH'd in. Ollama container: no models installed. The container had been running for 60+ days, every LLM call had been quietly hitting a hardcoded random-word fallback, and I had no signal because the failure mode was indistinguishable to users.
+
+The post-mortem covers the swap to Gemma 4 e4b that finally got the features running, plus the four production-only bugs that surfaced along the way:
+
+→ Floating Docker image tags lie about being "latest"
+→ cgroup memory limits never re-evaluated when the model changed
+→ Cold-load takes 60s, but my API timeout was 10s
+→ The parser silently dropped half of Gemma 4's output because qwen3's behavior had hidden a constraint
+
+Real numbers from a $20/month consumer VPS (no GPU): 9.6 GB on disk, 13 GiB RAM resident, 2.8 s warm inference.
+
+Full write-up: [POST URL]
+
+TextStack is open-source (AGPL-3.0) at https://github.com/mrviduus/textstack — if you've ever shipped local LLM features in production, a star helps the next person discover this story before they hit the same bugs.
+
+#opensource #selfhosted #localllm #gemma4 #dotnet #llmops
+```
+
+---
+
+## 7. Comment on the Gemma 4 Challenge launch post
+
+Drop on https://dev.to/devteam/join-the-gemma-4-challenge-3000-prize-pool-for-ten-winners-23in within 30 min of publishing the article. Jess Lee actively reads that thread.
+
+```
+Submitted my entry today: a post-mortem of swapping qwen3 → Gemma 4 e4b in production, after discovering the Ollama container had been silently empty for two months. Honest numbers from a $20 VPS (no GPU), and the four production bugs that surfaced when I actually flipped local LLM on.
+
+Build category — TextStack is the project: https://textstack.app
+
+Post: [POST URL]
+
+Thanks for organizing this challenge. The "intentional model selection" judging criterion was actually a useful prompt to write down why I picked E4B specifically vs. the other Gemma 4 variants — that's the kind of decision I usually don't document.
+```
+
+This works because: (a) it's substantive, not just "check out my post", (b) it credits the challenge for surfacing useful thinking, (c) Jess sees it.
+
+---
+
+## 8. Personal-network ask (DM template)
+
+For sending to 5–15 friends/colleagues you actually know who care about local LLM, .NET, or open-source. Don't spray-paste — adjust to each person.
+
+```
+Hey [Name],
+
+Published a post-mortem on Dev.to about silently shipping local LLM features that hadn't worked for two months in production — the swap to Gemma 4 e4b that finally got them running, and four bugs that surfaced.
+
+Submitted to the Gemma 4 Challenge ($500 prize, judged on tie-break by reactions) so a 👍 + 🦄 on the post helps real money:
+[POST URL]
+
+If you'd star the repo too, that's the higher-value signal for me long-term:
+https://github.com/mrviduus/textstack
+
+No worries if you don't have time. Cheers.
+```
+
+Personal asks convert at 5-10× cold reach. Send within 60 min of publishing while the boost window is open.
diff --git a/tests/TextStack.Extraction.Tests/FrontMatterFilterTests.cs b/tests/TextStack.Extraction.Tests/FrontMatterFilterTests.cs
index 22cc9481..61031e2c 100644
--- a/tests/TextStack.Extraction.Tests/FrontMatterFilterTests.cs
+++ b/tests/TextStack.Extraction.Tests/FrontMatterFilterTests.cs
@@ -38,4 +38,122 @@ public void IsTableOfContents_DoesNotMatch_OtherTitles(string? title)
{
Assert.False(FrontMatterFilter.IsTableOfContents(title));
}
+
+ // --- LooksLikeTableOfContentsBody ---
+
+ [Fact]
+ public void LooksLikeTableOfContentsBody_LeaderDottedEntries_MatchEvenWithoutTitle()
+ {
+ var paragraphs = new[]
+ {
+ "Preface ............ xi",
+ "Chapter 1 Introduction .......... 1",
+ "Chapter 2 Foundation Models ..... 49",
+ "Chapter 3 Evaluation ............ 111",
+ "Chapter 4 Inference ............. 145",
+ "Chapter 5 Production ............ 193",
+ "Index ........................... 271",
+ };
+
+ Assert.True(FrontMatterFilter.LooksLikeTableOfContentsBody(paragraphs));
+ }
+
+ [Fact]
+ public void LooksLikeTableOfContentsBody_EllipsisLeader_IsDetected()
+ {
+ var paragraphs = new[]
+ {
+ "Preface … xi",
+ "Chapter 1 Introduction … 1",
+ "Chapter 2 Foundation Models … 49",
+ "Chapter 3 Evaluation … 111",
+ "Chapter 4 Inference … 145",
+ };
+
+ Assert.True(FrontMatterFilter.LooksLikeTableOfContentsBody(paragraphs));
+ }
+
+ [Fact]
+ public void LooksLikeTableOfContentsBody_PlainProse_DoesNotMatch()
+ {
+ var paragraphs = new[]
+ {
+ "This book is geared toward technical roles.",
+ "It is for AI engineers, ML engineers, data scientists, and others.",
+ "You can also benefit if you work in tool development.",
+ "We will cover use cases, evaluation, and production deployment.",
+ "Reading this front matter gives you the lay of the land.",
+ "Each chapter ends with summaries and references for further study.",
+ };
+
+ Assert.False(FrontMatterFilter.LooksLikeTableOfContentsBody(paragraphs));
+ }
+
+ [Fact]
+ public void LooksLikeTableOfContentsBody_TooShort_DoesNotMatch()
+ {
+ // Conservative: under 5 substantive paragraphs we abstain rather than
+ // risk dropping a real short chapter that happens to end with a page-number.
+ var tooShort = new[] { "Preface ............ xi", "Chapter 1 .......... 1" };
+ Assert.False(FrontMatterFilter.LooksLikeTableOfContentsBody(tooShort));
+ }
+
+ [Fact]
+ public void LooksLikeTableOfContentsBody_NullOrEmpty_DoesNotMatch()
+ {
+ Assert.False(FrontMatterFilter.LooksLikeTableOfContentsBody(null));
+ Assert.False(FrontMatterFilter.LooksLikeTableOfContentsBody(Array.Empty