← Back to Tutorials
tutorials intermediate Elena Torres ·

Create an Automated Social Media Content Calendar with AI: 2026 Step-by-Step Guide

Create an Automated Social Media Content Calendar with AI: 2026 Step-by-Step Guide

Overview

Managing a social media calendar is one of the most repetitive yet critical tasks for content creators and marketers. Each post needs a unique angle, platform-appropriate formatting, hashtags, images, and scheduled timing — consuming hours every week.

This tutorial builds an automated system that:

  1. Generates a month of content from a single strategy document
  2. Adapts each post for Twitter, LinkedIn, and Instagram
  3. Creates image suggestions and hashtag sets
  4. Stores everything in a Google Sheet calendar
  5. Auto-posts via Buffer’s scheduling API
  6. Runs on a cron job for weekly regeneration

By the end, a single prompt like “Generate my February content calendar for my AI tools blog” produces 30+ ready-to-post items.

Architecture

┌──────────────┐     ┌──────────────┐     ┌───────────────┐
│  Strategy    │────▶│  Gemini 2.5  │────▶│  Content      │
│  Document    │     │  Content Gen │     │  Calendar     │
└──────────────┘     └──────────────┘     └───────┬───────┘


┌──────────────┐     ┌──────────────┐     ┌───────────────┐
│  Analytics   │◀────│  Buffer API  │◀────│  Human Review │
│  Dashboard   │     │  Auto-Post   │     │  & Approval   │
└──────────────┘     └──────────────┘     └───────────────┘

Prerequisites

  • Python 3.10+
  • Google AI API key
  • Google Cloud project (for Sheets API)
  • Buffer account (free tier supports 3 channels)

Step 1: Project Setup

mkdir ai-social-calendar && cd ai-social-calendar
python -m venv .venv
source .venv/bin/activate

pip install google-genai langchain-google-genai google-auth google-auth-oauthlib \
            google-auth-httplib2 google-api-python-client python-dotenv requests

Create .env:

GOOGLE_API_KEY=AIzaSy...
GOOGLE_SHEETS_ID=your-sheet-id-from-url
BUFFER_ACCESS_TOKEN=your-buffer-token

Step 2: Content Generation Engine

The core of the system is a LangChain chain that takes a strategy document and produces platform-specific posts.

Create content_generator.py:

import json
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain.prompts import ChatPromptTemplate
from langchain.schema.output_parser import StrOutputParser

load_dotenv()

llm = ChatGoogleGenerativeAI(
    model="models/gemini-2.5-flash-preview-04-17",
    temperature=0.8,
    google_api_key=os.getenv("GOOGLE_API_KEY"),
)

CONTENT_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """You are a social media content strategist. Generate engaging
posts based on the strategy document provided. For each post, generate versions
for Twitter, LinkedIn, and Instagram.

Current date: {current_date}

Rules:
- Twitter: ≤280 characters, 2-3 hashtags, conversational tone
- LinkedIn: 150-300 words, professional tone, 3-5 hashtags, question to engage
- Instagram: 100-200 words, visual description, 5-10 hashtags, emoji-friendly
- Each post must have a clear hook and call-to-action
- Vary post types: tips, questions, stories, data shares, polls, and quotes
- Space posts at least 4 hours apart on the same platform
- Do NOT include placeholder text like [insert image]

Return a JSON array where each entry has:
- date (YYYY-MM-DD)
- time (HH:MM in the platform's timezone)
- platform (twitter/linkedin/instagram)
- content (the actual post text)
- hashtags (array of strings)
- image_description (what visual to use)
- post_type (tip/question/story/data/poll/quote)
- topic (one-sentence topic summary)
"""),
    ("human", "Strategy document:\n{strategy}\n\nGenerate {num_days} days of posts across all three platforms.")
])

content_chain = CONTENT_PROMPT | llm | StrOutputParser()


def generate_monthly_calendar(
    strategy_text: str,
    num_days: int = 30,
) -> list[dict]:
    """
    Generate a month of social media content from a strategy document.

    Args:
        strategy_text: Description of brand, content themes, target audience
        num_days: Number of days to generate

    Returns:
        List of post dicts
    """
    current_date = datetime.now().strftime("%Y-%m-%d")

    raw = content_chain.invoke({
        "strategy": strategy_text,
        "num_days": str(num_days),
        "current_date": current_date,
    })

    # Parse JSON from response
    try:
        # Strip code block markers if present
        cleaned = raw.strip()
        if "```json" in cleaned:
            cleaned = cleaned.split("```json")[1].split("```")[0].strip()
        elif "```" in cleaned:
            cleaned = cleaned.split("```")[1].split("```")[0].strip()

        posts = json.loads(cleaned)
    except json.JSONDecodeError as e:
        print(f"JSON parse error: {e}")
        print("Raw response:")
        print(raw[:500])
        return []

    # Validate and enrich posts
    start_date = datetime.now() + timedelta(days=1)  # Start tomorrow
    for i, post in enumerate(posts):
        # Ensure unique dates/times
        day_offset = i // 3  # 3 posts per day (one per platform)
        hours = 9 + (i % 3) * 5  # 9 AM, 2 PM, 7 PM
        post_date = start_date + timedelta(days=day_offset)
        post["date"] = post_date.strftime("%Y-%m-%d")
        post["time"] = f"{hours:02d}:00"
        post["status"] = "draft"

    return posts


# Example strategy
SAMPLE_STRATEGY = """
Brand: ToolsDepth - a website reviewing and comparing AI tools
Audience: Developers, product managers, and tech enthusiasts aged 25-45
Tone: Professional but approachable, data-driven, honest
Content pillars:
1. AI tool reviews and comparisons (40%)
2. Tutorials and how-tos (30%)
3. Industry trends and analysis (20%)
4. Behind-the-scenes and team stories (10%)
Key differentiators: Real testing data, honest pros/cons, code examples
Goals: Increase newsletter signups, drive traffic to comparison pages
"""

if __name__ == "__main__":
    posts = generate_monthly_calendar(SAMPLE_STRATEGY)
    print(f"Generated {len(posts)} posts")
    for p in posts[:6]:  # Show first 6
        print(f"\n[{p['date']} {p['time']}] {p['platform'].upper()}")
        print(f"  Topic: {p.get('topic', 'N/A')}")
        print(f"  Content: {p['content'][:100]}...")
        print(f"  Hashtags: {', '.join(p.get('hashtags', []))}")

Step 3: Google Sheets Integration

Create sheets_manager.py to store the calendar:

import os
from google.auth import default
from google.oauth2 import service_account
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import pickle
from datetime import datetime

SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]


def get_google_sheets_service():
    """Authenticate and return Google Sheets service object."""
    creds = None

    # Token file stores user's access/refresh tokens
    if os.path.exists("token.pickle"):
        with open("token.pickle", "rb") as token:
            creds = pickle.load(token)

    # If no valid creds, let user log in
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                "credentials.json", SCOPES
            )
            creds = flow.run_local_server(port=0)

        # Save for next run
        with open("token.pickle", "wb") as token:
            pickle.dump(creds, token)

    return build("sheets", "v4", credentials=creds)


def write_calendar_to_sheet(posts: list[dict], spreadsheet_id: str):
    """Write posts to a Google Sheet in calendar format."""
    service = get_google_sheets_service()
    sheet = service.spreadsheets()

    # Headers
    headers = [
        ["Date", "Time", "Platform", "Content", "Hashtags",
         "Image Description", "Post Type", "Topic", "Status"]
    ]

    # Data rows
    rows = headers + [
        [
            p.get("date", ""),
            p.get("time", ""),
            p.get("platform", ""),
            p.get("content", ""),
            ", ".join(p.get("hashtags", [])),
            p.get("image_description", ""),
            p.get("post_type", ""),
            p.get("topic", ""),
            p.get("status", "draft"),
        ]
        for p in posts
    ]

    # Clear existing content and write new data
    sheet.values().clear(
        spreadsheetId=spreadsheet_id,
        range="A:I",
    ).execute()

    sheet.values().update(
        spreadsheetId=spreadsheet_id,
        range="A1",
        valueInputOption="RAW",
        body={"values": rows},
    ).execute()

    print(f"✓ Wrote {len(posts)} posts to sheet {spreadsheet_id}")

    # Format headers
    requests_body = {
        "requests": [
            {
                "repeatCell": {
                    "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 1},
                    "cell": {
                        "userEnteredFormat": {
                            "backgroundColor": {"red": 0.2, "green": 0.2, "blue": 0.2},
                            "textFormat": {
                                "foregroundColor": {"red": 1, "green": 1, "blue": 1},
                                "bold": True,
                            },
                        }
                    },
                    "fields": "userEnteredFormat(backgroundColor,textFormat)",
                }
            }
        ]
    }
    sheet.batchUpdate(spreadsheetId=spreadsheet_id, body=requests_body).execute()
    print("✓ Formatted header row")

Step 4: Buffer Auto-Posting

Create buffer_poster.py:

import os
import requests
import time
from datetime import datetime, timezone

BUFFER_API = "https://api.bufferapp.com/1"
CHANNEL_MAP = {
    "twitter": "twitter_profiles",  # Buffer channel type IDs
    "linkedin": "linkedin_pages",
    "instagram": "instagram_business_profiles",
}


def get_buffer_channels(access_token: str) -> list[dict]:
    """Get all connected social channels from Buffer."""
    resp = requests.get(
        f"{BUFFER_API}/profiles.json",
        params={"access_token": access_token},
    )
    resp.raise_for_status()
    return resp.json()


def schedule_post(
    access_token: str,
    profile_id: str,
    text: str,
    scheduled_at: str,  # ISO 8601 timestamp
) -> dict:
    """Schedule a single post via Buffer."""
    resp = requests.post(
        f"{BUFFER_API}/updates/create.json",
        params={
            "access_token": access_token,
            "profile_id": profile_id,
            "text": text,
            "scheduled_at": scheduled_at,
            "media[link]": "https://toolsdepth.com",  # Default link
        },
    )
    resp.raise_for_status()
    return resp.json()


def schedule_all_posts(posts: list[dict], access_token: str):
    """Schedule all approved posts via Buffer."""
    channels = get_buffer_channels(access_token)
    profile_map = {}
    for ch in channels:
        # Map service name to profile ID
        service = ch.get("service", "").lower()
        for platform in CHANNEL_MAP:
            if platform in service:
                profile_map[platform] = ch["id"]

    scheduled_count = 0
    for post in posts:
        if post.get("status") != "approved":
            continue

        platform = post.get("platform", "").lower()
        profile_id = profile_map.get(platform)
        if not profile_id:
            print(f"  ⚠ No Buffer profile for {platform}, skipping")
            continue

        # Build full content with hashtags
        content = post["content"]
        hashtags = post.get("hashtags", [])
        if hashtags:
            content += "\n\n" + " ".join(f"#{h.replace(' ', '')}" for h in hashtags)

        # Schedule time
        dt_str = f"{post['date']}T{post['time']}:00"
        scheduled_at = datetime.fromisoformat(dt_str).isoformat()

        result = schedule_post(access_token, profile_id, content, scheduled_at)
        print(f"  ✓ Scheduled {platform.upper()} post for {post['date']} {post['time']}")
        scheduled_count += 1

        # Rate limit: 1 post per second
        time.sleep(1)

    print(f"\nTotal scheduled: {scheduled_count} posts")
    return scheduled_count

Step 5: Main Pipeline

Create run.py:

import json
import os
from dotenv import load_dotenv
from content_generator import generate_monthly_calendar, SAMPLE_STRATEGY
from sheets_manager import write_calendar_to_sheet
from buffer_poster import schedule_all_posts

load_dotenv()


def run_pipeline(strategy_text: str = None, auto_approve: bool = False):
    """Full pipeline: generate → save to sheets → optionally post."""

    # Step 1: Generate content
    print("=" * 50)
    print("STEP 1: Generating content calendar")
    print("=" * 50)
    strategy = strategy_text or SAMPLE_STRATEGY
    posts = generate_monthly_calendar(strategy)
    print(f"Generated {len(posts)} posts")

    # Step 2: Save to Google Sheets
    print("\n" + "=" * 50)
    print("STEP 2: Writing to Google Sheets")
    print("=" * 50)
    sheet_id = os.getenv("GOOGLE_SHEETS_ID")
    if sheet_id:
        write_calendar_to_sheet(posts, sheet_id)
    else:
        print("⚠ GOOGLE_SHEETS_ID not set, saving to local JSON instead")
        with open("calendar.json", "w") as f:
            json.dump(posts, f, indent=2)

    # Step 3: Buffer posting (only approved posts)
    print("\n" + "=" * 50)
    print("STEP 3: Scheduling via Buffer")
    print("=" * 50)
    buffer_token = os.getenv("BUFFER_ACCESS_TOKEN")
    if buffer_token and auto_approve:
        # Auto-approve all posts for demo
        for p in posts:
            p["status"] = "approved"
        schedule_all_posts(posts, buffer_token)
    elif buffer_token:
        print("Posts saved to sheet for manual review.")
        print("Set status='approved' in the sheet, then re-run with --post")
    else:
        print("⚠ BUFFER_ACCESS_TOKEN not set, skipping scheduling")

    print("\n✓ Pipeline complete!")


if __name__ == "__main__":
    import sys
    auto_approve = "--post" in sys.argv
    strategy_file = None
    if "--strategy" in sys.argv:
        idx = sys.argv.index("--strategy")
        if len(sys.argv) > idx + 1:
            with open(sys.argv[idx + 1]) as f:
                strategy_file = f.read()

    run_pipeline(strategy_text=strategy_file, auto_approve=auto_approve)

Step 6: Setting Up the Automation

Run manually first, then automate:

# Generate and save to sheet
python run.py

# Generate, save, and auto-post
python run.py --post

# Use a custom strategy file
python run.py --strategy my_strategy.txt

Weekly cron job (crontab):

# Run every Monday at 9 AM to regenerate next week's content
0 9 * * 1 cd /path/to/ai-social-calendar && /path/to/.venv/bin/python run.py

Tips

  1. Human review gate: Always inspect the generated content before auto-posting. The AI sometimes produces outdated references or off-brand language. Set status to “needs review” by default.
  2. Image generation hookup: Add DALL-E or Stable Diffusion API calls to generate images based on image_description. Store the generated image URLs in the sheet.
  3. Seasonal content override: Add a “seasonal_events” section to your strategy document for holiday-specific posts. The AI handles this well with explicit dates.
  4. A/B test headlines: Generate two variants for each post and store both. Use the one with better engagement.

Common Pitfalls

  • ❌ Content too repetitive: Without variety instructions, AI defaults to “Here’s a tip” posts. Add post_type variety rules to your prompt (see the chat prompt template’s post_type examples).
  • ❌ Platform character limits: LinkedIn allows 3000 chars, but posts over 200 chars get truncated in feed previews. Keep LinkedIn posts under 200 chars for the preview, with a “Continue reading…” link.
  • ❌ Hashtag overuse: Instagram allows 30 hashtags, but using all 30 looks spammy. The sweet spot is 5-8 relevant hashtags. Enforce this in the prompt.
  • ❌ Timezone confusion: Buffer schedules in UTC. Your 9 AM post in Shanghai becomes 1 AM UTC. Always convert local time to UTC before scheduling via API.

Conclusion

You’ve built a complete AI-powered social media content calendar system. The pipeline takes a strategy document, generates 30+ platform-adapted posts, stores them in Google Sheets for review, and schedules them via Buffer.

The same architecture works for any scale — from a solo creator managing three platforms to a 10-person marketing team with 15 channels. Total API cost per month: roughly $2-5 in Gemini API fees for content generation. Compared to hiring a social media manager at $3,000+/month, this pays for itself in the first week.