Build a Viral Thread Writer: Turn Rough Outlines into Hooks with Gemini
What We’re Building
A Streamlit desktop tool that ingests a bullet-point outline and spits out a fully formatted, hook-driven Twitter/X thread. The heavy lifting is done by Gemini 1.5 Flash (free tier), which handles tone calibration, structural formatting, and the subtle art of not sounding like a corporate robot.
Feature list:
- Outline-to-thread generation: Paste 5-10 bullet points, get a 6-12 tweet thread.
- Hook-first architecture: The model is prompted to lead with a pattern-interrupt opener.
- Formatting engine: Auto-applies numbering (1/), line breaks, and emoji placement.
- Engagement scoring: Gemini returns a predicted virality score (1-10) with reasoning.
- One-click copy: Thread outputs are rendered in a copy-friendly block.
- Zero cost: Runs inside Gemini’s free tier rate limits (15 RPM, 1M TPM).
This isn’t a toy. It’s the same pattern FDEs use when a customer says “our social media manager spends 90 minutes drafting threads” and you ship a prototype by lunch.
Architecture
The flow is dead simple: Streamlit provides the UI layer, your outline hits a prompt-engineering middleware, Gemini generates the thread, and the result is rendered back in the browser. No vector DB, no RAG, no over-engineering.
The prompt constructor is where the magic lives. We’re not just throwing bullets at the model—we’re injecting system-level instructions that enforce structure, tone, and a scoring rubric. The response parser extracts the JSON payload (thread array + score object) so the UI can display it cleanly.
Prerequisites
Everything here is free-tier or open-source. No credit card required to start.
| Tool | Purpose | Free Tier Limit | Link |
|---|---|---|---|
| Gemini API | LLM for thread generation | 15 RPM, 1M TPM, 1,500 requests/day | aistudio.google.com |
| Python 3.10+ | Runtime | N/A | python.org |
| Streamlit | UI framework | N/A (open-source) | streamlit.io |
| google-generativeai | Gemini Python SDK | N/A | pip install google-generativeai |
Get your API key:
- Go to Google AI Studio.
- Click "Create API Key" (choose a new Google Cloud project if prompted).
- Copy the key. You’ll set it as an environment variable.
No GCP billing setup needed for the free tier. The key works out of the box.
Step 1: Project Setup and Dependencies
Open a terminal and scaffold the project:
mkdir thread-writer && cd thread-writer
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install streamlit google-generativeai python-dotenv
Create a .env file in the root (add it to .gitignore immediately):
GEMINI_API_KEY=your-api-key-here
Create the main app file:
touch app.py
Now verify your key works with a quick smoke test:
# test_key.py
import google.generativeai as genai
import os
from dotenv import load_dotenv
load_dotenv()
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
model = genai.GenerativeModel("gemini-1.5-flash")
response = model.generate_content("Reply with just the word 'connected'")
print(response.text)
Run python test_key.py. If you see "connected", you’re in business. Delete the test file after.
Step 2: The Prompt Engineering Core
This is the engine. The prompt does five things simultaneously:
- Constrains output to parseable JSON.
- Enforces a hook-first structure.
- Sets a conversational, high-signal tone.
- Limits thread length to avoid rate-limit fatigue.
- Returns a virality score with explicit reasoning.
Create prompt_builder.py:
def build_thread_prompt(outline: str, tone: str = "sharp", thread_length: int = 8) -> str:
tone_map = {
"sharp": "Direct, high-signal, no fluff. Short sentences. Engineer-to-engineer.",
"storyteller": "Narrative-driven. Open with a personal anecdote or observation.",
"contrarian": "Challenge conventional wisdom. Lead with a bold, debatable claim."
}
tone_desc = tone_map.get(tone, tone_map["sharp"])
return f"""You are an expert Twitter/X ghostwriter who turns rough outlines into viral threads.
TONE: {tone_desc}
RULES:
- Output ONLY valid JSON. No markdown fences, no preamble.
- The JSON must have two keys: "thread" (array of strings, each ≤280 characters) and "score" (object with keys "rating" [integer 1-10] and "reasoning" [string]).
- The first tweet MUST be a scroll-stopping hook. Use pattern interrupts, bold claims, or curiosity gaps.
- Each subsequent tweet must flow logically from the previous one.
- End with a strong CTA (call-to-action) or thought-provoking question.
- Use numbered format internally (e.g., "3/ Another angle:") but do NOT include the number in the output string—the UI will handle numbering.
- Total thread length: {thread_length} tweets.
- No hashtag stuffing. Max 2 hashtags total, placed only in the final tweet.
OUTLINE:
{outline}
JSON OUTPUT:"""
Key design decisions:
- JSON-only output eliminates parsing headaches. Gemini is good at structured output when you explicitly forbid markdown fences.
- Tone injection via a dictionary lets us expose a dropdown in the UI without bloating the prompt.
- 280-char cap is enforced by instruction, not code truncation. Gemini respects character limits surprisingly well.
Step 3: Building the Streamlit UI
Open app.py. We’re building a single-page app with three sections: input, controls, and output.
import streamlit as st
import google.generativeai as genai
import json
import os
from dotenv import load_dotenv
from prompt_builder import build_thread_prompt
load_dotenv()
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
st.set_page_config(page_title="Thread Writer | FDE Coach", page_icon="🧵", layout="wide")
st.title("🧵 Thread Writer")
st.caption("Turn bullet-point outlines into formatted, hook-driven threads. Powered by Gemini 1.5 Flash (free tier).")
# --- Sidebar Controls ---
with st.sidebar:
st.header("⚙️ Controls")
tone = st.selectbox(
"Tone",
options=["sharp", "storyteller", "contrarian"],
index=0,
help="Sharp: direct and high-signal. Storyteller: narrative-driven. Contrarian: bold claims."
)
thread_length = st.slider("Thread length", min_value=5, max_value=15, value=8, step=1)
st.divider()
st.markdown("**Rate limits:** 15 RPM, 1,500 req/day on free tier.")
st.markdown("[Get API key](https://aistudio.google.com/apikey)")
# --- Main Input Area ---
st.subheader("📝 Paste your outline")
st.caption("One bullet per line. 5-10 bullets works best.")
outline_input = st.text_area(
"Outline",
height=200,
placeholder="""- Why most SaaS onboarding emails fail
- The cognitive load problem
- Real example from a $50M ARR company
- The 3-email sequence that fixed it
- Results: 40% activation lift
- Key takeaway for founders""",
label_visibility="collapsed"
)
generate_btn = st.button("⚡ Generate Thread", type="primary", disabled=not outline_input.strip())
# --- Output Area ---
if "thread_data" not in st.session_state:
st.session_state.thread_data = None
The sidebar keeps controls accessible without cluttering the main workflow. The disabled prop on the button prevents empty-submit errors.
Step 4: Wiring the Gemini API
Continue in app.py, below the UI code:
if generate_btn and outline_input.strip():
with st.spinner("Gemini is drafting your thread..."):
try:
model = genai.GenerativeModel("gemini-1.5-flash")
prompt = build_thread_prompt(outline_input, tone, thread_length)
# Safety settings: allow all content for creative writing
response = model.generate_content(
prompt,
generation_config=genai.types.GenerationConfig(
temperature=0.9, # Higher creativity for hooks
top_p=0.95,
max_output_tokens=2048,
),
safety_settings=[
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
]
)
# Parse the JSON response
raw_text = response.text.strip()
# Strip markdown fences if Gemini ignores instructions
if raw_text.startswith("```"):
raw_text = raw_text.split("```")[1]
if raw_text.startswith("json"):
raw_text = raw_text[4:]
raw_text = raw_text.strip()
parsed = json.loads(raw_text)
st.session_state.thread_data = parsed
except json.JSONDecodeError as e:
st.error(f"JSON parsing failed. Raw response:\n```\n{raw_text[:500]}\n```")
except Exception as e:
st.error(f"API error: {str(e)}")
# --- Render Output ---
if st.session_state.thread_data:
data = st.session_state.thread_data
thread = data.get("thread", [])
score = data.get("score", {})
st.divider()
st.subheader("🧵 Generated Thread")
# Score card
col1, col2 = st.columns([1, 3])
with col1:
st.metric("Predicted Virality", f"{score.get('rating', 'N/A')}/10")
with col2:
st.caption(f"💡 {score.get('reasoning', '')}")
# Thread display
thread_text = ""
for i, tweet in enumerate(thread, 1):
thread_text += f"{i}/ {tweet}\n\n"
st.code(thread_text, language=None, wrap_lines=True)
# Copy button
st.button("📋 Copy to Clipboard", on_click=lambda: st.write("Use the code block above—Streamlit doesn't support native clipboard yet."))
# Raw JSON toggle for debugging
with st.expander("🔍 Raw JSON Response"):
st.json(data)
Why temperature 0.9: Thread writing is creative work. We want variety in hooks, not deterministic output. If you’re generating threads for a regulated industry (finance, healthcare), drop it to 0.3-0.5.
Safety settings: We disable all harm filters because legitimate marketing threads can trigger false positives on categories like “dangerous content” (e.g., growth hacking tactics). In production, you’d tune these per use case.
Step 5: Running the App
From the project root:
streamlit run app.py
Streamlit opens a browser tab at http://localhost:8501. Paste an outline, hit generate, and you’ll have a formatted thread in under 5 seconds.
Quick test outline to paste:
- Why most cold emails fail in 2025
- The "pattern interrupt" framework
- Real data from 10,000 emails
- The 3-sentence rule
- How to A/B test subject lines without tools
- Results and key takeaways
Sensible Extensions
Once the core works, here’s where to take it:
-
Multi-platform formatting: Add a toggle for LinkedIn (longer posts, professional tone) or Threads (shorter, more casual). The prompt builder already supports tone injection—just add platform-specific formatting rules.
-
Thread history and iteration: Store generated threads in
st.session_statewith a “Regenerate with feedback” input. The user types “make it funnier” and you pass the previous thread + feedback as context. -
Analytics dashboard: Track which outlines produce the highest virality scores. Dump generations to a local SQLite DB and build a simple Streamlit dashboard. This is exactly the kind of prototype that becomes a product feature—similar to the feedback loops we cover in How FDEs Work with Product and Engineering After the Sale.
-
Scheduled generation: Wrap the core logic in a cron job that pulls outlines from a Google Sheet and posts drafts to a Slack channel. The pattern is identical to what we built in the Daily Standup Bot with n8n and Gemini guide.
-
RAG on past viral threads: Scrape your top-performing tweets, embed them, and inject the most similar examples into the prompt as few-shot demonstrations. This is advanced but dramatically improves output quality.
Common Pitfalls
Pitfall 1: Gemini returns markdown-fenced JSON.
Even when you explicitly say “no markdown fences,” Gemini sometimes wraps JSON in ```json blocks. The parsing code handles this by stripping fences, but if you see persistent issues, add: Do NOT wrap the JSON in code blocks. Start directly with {.
Pitfall 2: Threads exceeding 280 characters. Gemini respects character limits about 80% of the time. For production, add a post-processing step that truncates tweets at 280 characters with an ellipsis. A simple list comprehension does it:
thread = [t[:277] + "..." if len(t) > 280 else t for t in thread]
Pitfall 3: Rate limit 429 errors. The free tier allows 15 requests per minute. If you’re iterating rapidly, you’ll hit this. Add a simple retry with exponential backoff:
import time
for attempt in range(3):
try:
response = model.generate_content(prompt)
break
except Exception as e:
if "429" in str(e) and attempt < 2:
time.sleep(2 ** attempt)
else:
raise
Pitfall 4: Generic, low-energy hooks. If the model defaults to “In this thread I will explain…” openers, your tone description is too vague. Use stronger language in the tone map: “Open with a counterintuitive claim or a specific number. Never use ‘In this thread’ or ‘Here’s why’.”
Pitfall 5: Forgetting the .env file in production.
When deploying to Streamlit Cloud, use their secrets manager instead of .env. The pattern: st.secrets["GEMINI_API_KEY"].
FAQ
Q: Is this really free? What’s the catch? Yes. Gemini 1.5 Flash free tier gives you 1,500 requests per day at 15 RPM. For a single-user tool generating 20-30 threads per day, you’ll never hit the cap. Google uses the data to improve their models per the standard API terms—don’t paste proprietary outlines if that’s a concern.
Q: Can I use this for client work as an FDE? Absolutely. This is a classic FDE pattern: identify a repetitive task, wrap an LLM around it, ship a prototype in an afternoon. The code is modular enough to extract the core generation logic and embed it in a larger workflow. For more on this approach, see What a Forward Deployed Engineer Actually Does in a Week.
Q: How do I make the threads actually go viral? The model predicts virality based on structural patterns (hooks, curiosity gaps, strong CTAs). It’s directionally accurate but not a crystal ball. Pair it with real analytics: post the threads, track impressions, and feed high-performers back as examples. This feedback loop is the same principle we explore in Scaling Yourself: When an FDE Hands Off a Prototype.
Q: Why Streamlit instead of a CLI or web app? Streamlit is the fastest path from idea to interactive prototype. No HTML, no CSS, no JavaScript. When you’re embedding with a customer and need to show progress by end of day, Streamlit wins. You can always graduate to FastAPI + React later.
Q: Can I use OpenAI instead of Gemini?
Yes, the architecture is model-agnostic. Swap the google-generativeai SDK for openai, replace the prompt builder’s output format instructions with OpenAI’s JSON mode, and you’re done. Gemini is chosen here for its genuinely free tier—OpenAI’s free tier is credit-limited and expires.
Q: The generated thread feels repetitive. How do I fix it? Bump temperature to 1.0 and add “Vary sentence length dramatically. Mix 3-word sentences with 25-word sentences” to your tone description. Also, ensure your outline has diverse bullet points—garbage in, garbage out still applies.
Q: How do I deploy this for my team?
Streamlit Cloud offers a free tier for public repos. Push to GitHub, connect the repo, set GEMINI_API_KEY in secrets, and you’re live in 5 minutes. For internal tools, Streamlit’s authentication hooks let you add Google OAuth with ~20 lines of code.
Built something cool with this guide? The FDE mindset is about shipping fast and iterating based on real feedback. If you’re looking to level up your prototyping skills or need a hands-on partner for customer-facing AI builds, FDE Coach has your back.
Want to build like a Forward Deployed Engineer?
FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.
Explore the program