Content & Marketing

Generate Twitter Threads with Python and AI

This guide shows you how to turn a topic or an article into a clean, numbered X/Twitter thread with Python in under fifteen minutes. You hand the script a subject, it drafts a hook, the supporting posts, and a closing call to action (the "do this next" line at the end), enforces a character limit on every post so none get rejected, and either prints the thread for you to copy or publishes it for you.

Writing threads by hand is slow, and pasting a topic into a chat window gives you a wall of text you still have to chop into posts. A small script fixes both problems and slots neatly into your wider Automated Social Media Posting routine.

Prerequisites

You only need a few things beyond a working Python install. If you have not set Python up yet, start with Setting Up Python for AI and come back here.

  • Python 3.10 or newer. Check with python --version.
  • An OpenAI API key. If the key step is new to you, the parent track Understanding LLM APIs walks through getting and testing one.
  • A virtual environment so this project's packages stay isolated. See Create a Python Virtual Environment for AI if you have not made one before.
  • An X API key and access token — only if you want Step 4 to publish automatically. You can skip this and copy the thread by hand.

1. Install dependencies and set up credentials

Activate your virtual environment, then install the two libraries this script uses. We prefer httpx over the older requests library because it handles modern HTTP cleanly and is the library the openai SDK already depends on.

pip install openai httpx python-dotenv

Create a file named .env in your project folder and add your keys. The X values are only needed for auto-posting in Step 4.

OPENAI_API_KEY=sk-your-openai-key
X_BEARER_TOKEN=your-x-oauth2-user-token

Add .env to your .gitignore file immediately so you never commit your secret keys to version control.

echo ".env" >> .gitignore

2. Generate the thread with the OpenAI SDK

Now ask the model to draft the thread. The key trick is the prompt (the instructions you send the model): we ask for a numbered list of short posts, name the hook and call to action explicitly, and request plain JSON so Python can read the result reliably. We do not trust the model to count characters — that comes in Step 3.

import os
import json
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


def draft_thread(topic: str, target_posts: int = 7, limit: int = 280) -> list[str]:
    prompt = f"""Write an engaging X/Twitter thread about: "{topic}".

Rules:
- Produce about {target_posts} short posts.
- Post 1 is a scroll-stopping HOOK with a bold claim or question.
- Each middle post makes exactly one clear point.
- The final post is a call to action (ask readers to follow, reply, or share).
- Keep every post under {limit} characters. Do not number them yourself.
- No hashtags except at most one in the final post.

Return ONLY valid JSON: {{"posts": ["post one", "post two", ...]}}"""

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"},
    )
    data = json.loads(response.choices[0].message.content)
    return data["posts"]

Setting response_format={"type": "json_object"} tells the model to return strictly valid JSON, which stops the script crashing on stray text. If you want to feed in a full article instead of a short topic, just pass the article text as the topic argument — the same prompt summarizes it into a thread. For deeper control over output shape, see Write System Prompts that Control Output Format.

3. Validate and enforce per-tweet character limits

Language models cannot count characters reliably, so the model's "under 280" promise is only a suggestion. Python is the source of truth. This step walks every post, and any that overshoots your limit is split on a sentence or word boundary so no tweet is ever rejected by X.

def enforce_limit(posts: list[str], limit: int = 280) -> list[str]:
    cleaned: list[str] = []
    for post in posts:
        text = post.strip()
        while len(text) > limit:
            # Find the last space before the limit to avoid cutting a word.
            cut = text.rfind(" ", 0, limit)
            if cut == -1:  # one very long word; hard cut.
                cut = limit
            cleaned.append(text[:cut].strip())
            text = text[cut:].strip()
        if text:
            cleaned.append(text)
    return cleaned

This guarantees every returned post fits. Because splitting can add posts, validate after generation rather than fighting the model to hit an exact count. If you would rather reserve room for the "1/8" counter you add in the next step, lower the limit you pass here by about six characters.

4. Print a clean numbered list or post via httpx

Finally, number the thread for readability and either print it to copy by hand or publish it. Numbering uses an n/total format so readers know how far through they are. Posting to X uses the v2 /2/tweets endpoint, where each reply points at the previous post's ID with in_reply_to_tweet_id to keep the thread connected.

import httpx


def number_thread(posts: list[str]) -> list[str]:
    total = len(posts)
    return [f"{post}\n\n{i}/{total}" for i, post in enumerate(posts, start=1)]


def print_thread(posts: list[str]) -> None:
    for post in posts:
        print(post)
        print("-" * 40)


def post_to_x(posts: list[str]) -> list[str]:
    headers = {"Authorization": f"Bearer {os.getenv('X_BEARER_TOKEN')}"}
    ids: list[str] = []
    with httpx.Client(timeout=30) as http:
        reply_to = None
        for post in posts:
            payload = {"text": post}
            if reply_to:
                payload["reply"] = {"in_reply_to_tweet_id": reply_to}
            res = http.post(
                "https://api.twitter.com/2/tweets",
                headers=headers,
                json=payload,
            )
            res.raise_for_status()
            reply_to = res.json()["data"]["id"]
            ids.append(reply_to)
    return ids

Call print_thread while you are testing and only switch to post_to_x once the output reads well. Posting in order matters: each reply must wait for the previous post's ID, so never send them in parallel.

Quick reference

These are the parameters you will tune most often.

ParameterTypeDefaultEffect
target_postsint7Roughly how many posts the model drafts before splitting.
limitint280Hard character ceiling enforced per post by enforce_limit.
modelstrgpt-4o-miniWhich OpenAI model writes the thread; larger models suit long articles.

Worked example

This script ties the four steps together. Run it as-is to print a thread; uncomment the final line to publish.

import os
import json
import httpx
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


def draft_thread(topic: str, target_posts: int = 7, limit: int = 280) -> list[str]:
    prompt = f"""Write an engaging X/Twitter thread about: "{topic}".
Post 1 is a scroll-stopping HOOK. Each middle post makes one point.
The final post is a call to action. Keep each post under {limit} chars.
Aim for about {target_posts} posts. Do not number them.
Return ONLY JSON: {{"posts": ["...", "..."]}}"""
    res = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"},
    )
    return json.loads(res.choices[0].message.content)["posts"]


def enforce_limit(posts: list[str], limit: int = 274) -> list[str]:
    cleaned: list[str] = []
    for post in posts:                       # reserve ~6 chars for the n/total tag
        text = post.strip()
        while len(text) > limit:
            cut = text.rfind(" ", 0, limit)
            cut = limit if cut == -1 else cut
            cleaned.append(text[:cut].strip())
            text = text[cut:].strip()
        if text:
            cleaned.append(text)
    return cleaned


def number_thread(posts: list[str]) -> list[str]:
    total = len(posts)
    return [f"{p}\n\n{i}/{total}" for i, p in enumerate(posts, start=1)]


def post_to_x(posts: list[str]) -> list[str]:
    headers = {"Authorization": f"Bearer {os.getenv('X_BEARER_TOKEN')}"}
    ids, reply_to = [], None
    with httpx.Client(timeout=30) as http:
        for post in posts:
            payload = {"text": post}
            if reply_to:
                payload["reply"] = {"in_reply_to_tweet_id": reply_to}
            res = http.post("https://api.twitter.com/2/tweets", headers=headers, json=payload)
            res.raise_for_status()
            reply_to = res.json()["data"]["id"]
            ids.append(reply_to)
    return ids


if __name__ == "__main__":
    raw = draft_thread("Why non-developers should learn Python in 2026")
    thread = number_thread(enforce_limit(raw))
    for post in thread:
        print(post)
        print("-" * 40)
    # post_to_x(thread)   # uncomment to publish to X

Troubleshooting

  1. json.decoder.JSONDecodeError — the model returned text that is not valid JSON. Confirm response_format={"type": "json_object"} is set and that your prompt contains the word "JSON". If it persists, see Fix JSONDecodeError with AI API Responses in Python.
  2. A tweet is rejected for being too long — you are posting the raw model output instead of running enforce_limit first. Always validate length in Python, and remember the n/total tag adds about six characters, so lower the limit you pass accordingly.
  3. 401 Unauthorized from the X API — your bearer token is missing, expired, or lacks write permission. Regenerate a user-context OAuth 2.0 token with the tweet.write scope in the X developer portal, not an app-only token. The OpenAI equivalent is covered in Fix the 401 Unauthorized Error in OpenAI Python.
  4. 429 Too Many Requests while posting — you hit X's per-window write cap, which is low on free tiers. Add a short time.sleep(2) between posts and avoid re-running the whole thread on every test. The same backoff idea is explained in Fix the 429 Rate-Limit Error in Python.

When to use this vs. alternatives

  • Use this script when you publish threads regularly and want consistent hooks, enforced limits, and the option to draft from a topic or a full article in one step. It is free to run beyond a few cents of API cost and needs no third-party subscription.
  • Use a scheduling tool instead when your main need is timing many posts across several networks rather than generating thread copy. For a Python-native approach to that, see Bulk-Schedule Social Posts with Python.
  • Use a single-image flow instead when the platform is visual rather than text-first. For that, Schedule Instagram Posts Using Python and AI is the better fit, since Instagram rewards one strong image and caption over a chain of text posts.

Next steps

Once your threads read well, batch the whole pipeline so one run drafts a week of content, then hand the queue to Bulk-Schedule Social Posts with Python for timed delivery. Back to Automated Social Media Posting.