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:
- Generates a month of content from a single strategy document
- Adapts each post for Twitter, LinkedIn, and Instagram
- Creates image suggestions and hashtag sets
- Stores everything in a Google Sheet calendar
- Auto-posts via Buffer’s scheduling API
- 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
- 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.
- 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. - Seasonal content override: Add a “seasonal_events” section to your strategy document for holiday-specific posts. The AI handles this well with explicit dates.
- 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_typevariety 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.