Chapter 48
social media publishing agent publora langgraph
Social Media Publishing Agent (Publora + LangGraph)
Overview
Most "content agents" stop at writing a post. This tutorial builds an agent that closes the loop: it takes a single raw idea, writes a tailored version for each target social platform, critiques and revises its own drafts until they pass platform rules, and then actually publishes them to real accounts through the Publora API — one scheduling call per platform.
The agent is built with LangGraph for orchestration, an OpenAI chat model for generation and self-review, and Publora as the publishing layer (a single REST API / MCP server that fans out to 10 social platforms: LinkedIn, X, Instagram, Threads, TikTok, YouTube, Facebook, Bluesky, Mastodon, and Telegram).
This notebook focuses on the text-only platforms (X, LinkedIn, Bluesky, Threads, Mastodon) so it runs end-to-end without media handling. Publora also supports image/video platforms via
mediaUrls; see Additional Considerations.
Motivation
Publishing to social media by hand is repetitive and doesn't scale: the same idea has to be rewritten for each network's length limits, tone, and hashtag conventions, then pasted into each dashboard. An LLM can draft the variants, but a naive "generate and paste" pipeline has two problems:
- No quality gate — the model happily returns a 400-character "tweet" that the platform will reject.
- No delivery — someone still has to log in to five sites and post at the right time.
This agent fixes both. A self-review loop enforces per-platform constraints before anything is sent, and Publora handles authenticated delivery and scheduling so the agent finishes the job.
Key Components
- Connection resolver — reads the user's connected accounts from Publora (
GET /platform-connections) and maps each requested platform to a real connection ID. Requested platforms that aren't connected are skipped with a warning. - Content generator — for each connected platform, an LLM writes a variant that respects that platform's character budget and voice.
- Reviewer (reflection loop) — a deterministic length check plus an LLM critic. Failing drafts are sent back to the generator with targeted feedback, bounded by a max-iteration guard.
- Publisher — schedules each approved draft through Publora (
POST /create-post), one call per platform because each carries different content. Idempotency keys make retries safe.
Agent Architecture
fetch_connections → generate → review → (revise loop) → publish. The review node is the only branch point: if it produces feedback and we're under the iteration cap, control returns to generate; otherwise it proceeds to publish.
Benefits
- Correct by construction — posts are validated against platform limits before they leave the process.
- Actually ships — the agent publishes/schedules instead of handing you text to copy-paste.
- One integration, ten platforms — Publora abstracts per-network OAuth and APIs behind a single key.
- Safe to demo — a dry-run mode creates drafts (or just prints payloads) so nothing goes live while you experiment.
Visual Representation
Implementation
Required packages
!pip install -q langgraph langchain-openai pydantic requestsConfiguration & keys
Two credentials are used:
OPENAI_API_KEY— required, for content generation and review.PUBLORA_API_KEY— optional. Get a free key at publora.com → API. If it is not set, the notebook runs in dry-run mode: it mocks your connected accounts and prints the payloads instead of calling Publora, so the whole graph still executes.
DRY_RUN = True is an extra safety switch: even with a Publora key, drafts are created without a scheduled time, so posts are saved as drafts and nothing is published. Flip it to False (and keep a schedule_time) to actually schedule.
import os
import json
import requests
from typing import TypedDict, Optional
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") # required for generation/review
PUBLORA_API_KEY = os.environ.get("PUBLORA_API_KEY") # optional -> dry-run if unset
PUBLORA_BASE = "https://api.publora.com/api/v1"
DRY_RUN = True # True -> create drafts (or print). False -> actually schedule at schedule_time.
MAX_ITERS = 2 # max self-revision rounds before publishing what we havePlatform specifications
Each platform gets its own character budget and voice. These are the text-only networks; image/video platforms (Instagram, TikTok, YouTube) require media and are covered in Additional Considerations.
PLATFORM_SPECS = {
"twitter": {"max_chars": 280, "style": "punchy and concrete; a strong hook in the first line; at most 1-2 hashtags"},
"linkedin": {"max_chars": 3000, "style": "professional and value-driven; short paragraphs; one clear takeaway; no hashtag spam"},
"bluesky": {"max_chars": 300, "style": "conversational and authentic; minimal hashtags; a builder-to-builder tone"},
"threads": {"max_chars": 500, "style": "casual and engaging; invites replies; light, human voice"},
"mastodon": {"max_chars": 500, "style": "informative and community-friendly; plain, non-salesy"},
}Publora client
A thin wrapper over the two REST endpoints we need. Authentication is a single header, x-publora-key; keys never expire. create-post with a scheduledTime schedules the post; omitting it creates a draft.
def publora_request(method: str, path: str, body: dict | None = None, idempotency_key: str | None = None) -> dict:
"""Authenticated call to the Publora REST API."""
headers = {"x-publora-key": PUBLORA_API_KEY}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
resp = requests.request(method, f"{PUBLORA_BASE}/{path}", headers=headers, json=body, timeout=30)
if resp.status_code == 401:
raise RuntimeError("Invalid PUBLORA_API_KEY - generate one at publora.com -> API")
resp.raise_for_status()
return resp.json()
def get_connections() -> list:
"""Return the list of connected social accounts for this key."""
return publora_request("GET", "platform-connections")["connections"]
def create_post(content: str, platform_id: str, scheduled_time: str | None = None) -> dict:
"""Schedule (or draft) one post. One platform_id per call so each can carry tailored content."""
body = {"content": content, "platforms": [platform_id]}
if scheduled_time:
body["scheduledTime"] = scheduled_time
return publora_request("POST", "create-post", body=body, idempotency_key=str(uuid4()))Language model and structured schemas
We use structured outputs so the model returns clean objects, not prose we have to parse.
def get_llm():
if not OPENAI_API_KEY:
raise RuntimeError("Set OPENAI_API_KEY to generate content.")
return ChatOpenAI(model="gpt-4o-mini", temperature=0.7, api_key=OPENAI_API_KEY)
class PlatformPost(BaseModel):
content: str = Field(description="The ready-to-publish post text for this platform.")
class Critique(BaseModel):
approved: bool = Field(description="True if the post is ready to publish as-is.")
note: str = Field(description="If not approved, a short, specific instruction on how to fix it.")Agent state
class AgentState(TypedDict):
idea: str # the raw input idea
target_platforms: list # platforms the user asked for
connections: dict # platform -> real Publora platformId
drafts: dict # platform -> generated content
feedback: dict # platform -> revision instruction (from review)
iteration: int # how many review rounds have run
schedule_time: Optional[str] # ISO 8601 UTC; used only when DRY_RUN is False
results: list # per-platform publish resultsGraph nodes
1. fetch_connections — resolve requested platforms to real connection IDs. Without a Publora key we mock them so the demo still runs.
def fetch_connections(state: AgentState) -> dict:
targets = state["target_platforms"]
if PUBLORA_API_KEY:
mapping = {}
for c in get_connections():
prefix = c["platformId"].split("-", 1)[0] # "twitter-123" -> "twitter"
mapping.setdefault(prefix, c["platformId"]) # first account per platform
connections = {p: mapping[p] for p in targets if p in mapping}
missing = [p for p in targets if p not in mapping]
if missing:
print(f"Not connected in your Publora account, skipping: {missing}")
else:
connections = {p: f"{p}-DEMO123" for p in targets}
print("No PUBLORA_API_KEY set - using mock connections (dry-run).")
print(f"Publishing to: {list(connections)}")
return {"connections": connections}2. generate — write (or rewrite) a draft for each connected platform. On a revision round it only regenerates platforms that received feedback.
def generate(state: AgentState) -> dict:
llm = get_llm().with_structured_output(PlatformPost)
drafts = dict(state.get("drafts") or {})
feedback = state.get("feedback") or {}
platforms = [p for p in state["target_platforms"] if p in state["connections"]]
for p in platforms:
if p not in PLATFORM_SPECS:
print(f"No spec for '{p}' - add it to PLATFORM_SPECS. Skipping.")
continue
if p in drafts and p not in feedback:
continue # already approved, leave it
spec = PLATFORM_SPECS[p]
instruction = (
f"You are a social media copywriter. Write a single {p} post about the idea below.\n"
f"Idea: {state['idea']}\n\n"
f"Constraints:\n"
f"- Hard limit: {spec['max_chars']} characters.\n"
f"- Voice: {spec['style']}.\n"
f"- No preamble, no quotes around the post, return only the post text."
)
if p in feedback:
instruction += f"\n\nRevise your previous draft. Reviewer feedback: {feedback[p]}\nPrevious draft: {drafts.get(p, '')}"
result = llm.invoke(instruction)
drafts[p] = result.content.strip()
return {"drafts": drafts, "feedback": {}}3. review — a deterministic length gate plus an LLM critic. Anything that fails produces feedback for another round.
def review(state: AgentState) -> dict:
llm = get_llm().with_structured_output(Critique)
feedback = {}
for p, content in state["drafts"].items():
spec = PLATFORM_SPECS[p]
if len(content) > spec["max_chars"]:
feedback[p] = f"Too long: {len(content)}/{spec['max_chars']} chars. Cut it down while keeping the hook."
continue
verdict = llm.invoke(
f"Review this {p} post. Platform voice: {spec['style']}. "
f"Approve only if it is on-idea, on-voice, and publish-ready.\n\nPost:\n{content}"
)
if not verdict.approved:
feedback[p] = verdict.note
kept = {p: state["drafts"][p] for p in state["drafts"] if p not in feedback}
print(f"Review round {state.get('iteration', 0) + 1}: {len(kept)} approved, {len(feedback)} need revision.")
return {"feedback": feedback, "iteration": state.get("iteration", 0) + 1}4. Routing — loop back to generate while there is feedback and we're under the cap; otherwise publish.
def route_after_review(state: AgentState) -> str:
if state["feedback"] and state["iteration"] < MAX_ITERS:
return "generate"
return "publish"5. publish — send each approved draft to Publora. Dry-run (or no key) never goes live.
def publish(state: AgentState) -> dict:
results = []
for p, content in state["drafts"].items():
platform_id = state["connections"][p]
if not PUBLORA_API_KEY:
print(f"[DRY-RUN] would POST create-post -> {platform_id}: {content[:60]}...")
results.append({"platform": p, "status": "dry-run", "postGroupId": f"dry-{uuid4().hex[:8]}"})
continue
scheduled = None if DRY_RUN else state.get("schedule_time")
try:
resp = create_post(content, platform_id, scheduled_time=scheduled)
results.append({
"platform": p,
"status": "scheduled" if scheduled else "draft",
"postGroupId": resp.get("postGroupId"),
"scheduledTime": resp.get("scheduledTime"),
})
except Exception as exc: # isolate one platform's failure from the rest
print(f"Failed to publish to {p}: {exc}")
results.append({"platform": p, "status": "error", "error": str(exc)})
return {"results": results}Assemble the graph
builder = StateGraph(AgentState)
builder.add_node("fetch_connections", fetch_connections)
builder.add_node("generate", generate)
builder.add_node("review", review)
builder.add_node("publish", publish)
builder.set_entry_point("fetch_connections")
builder.add_edge("fetch_connections", "generate")
builder.add_edge("generate", "review")
builder.add_conditional_edges("review", route_after_review, {"generate": "generate", "publish": "publish"})
builder.add_edge("publish", END)
agent = builder.compile()Usage example
Runs top-to-bottom. With no PUBLORA_API_KEY it prints what it would publish; with a key and DRY_RUN = True it creates real drafts you can inspect in your Publora dashboard.
initial_state = {
"idea": "We just shipped a REST + MCP API that lets AI agents publish to 10 social platforms with a single call.",
"target_platforms": ["twitter", "linkedin", "bluesky"],
"connections": {},
"drafts": {},
"feedback": {},
"iteration": 0,
"schedule_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"results": [],
}
final_state = agent.invoke(initial_state)
print("\n===== DRAFTS =====")
for platform, content in final_state["drafts"].items():
print(f"\n--- {platform} ({len(content)} chars) ---\n{content}")
print("\n===== PUBLISH RESULTS =====")
print(json.dumps(final_state["results"], indent=2))Comparison with a simpler agent
| Naive one-shot generator | This agent | |
|---|---|---|
| Per-platform tailoring | One text reused everywhere | A variant per platform, within each character budget |
| Quality gate | None — may exceed limits | Deterministic length check + LLM critic with a bounded revise loop |
| Delivery | Returns text to copy-paste | Schedules/publishes via Publora, returns postGroupIds |
| Failure handling | Manual | Skips unconnected/unknown platforms; one platform's failure is isolated; a per-request idempotency key makes a call's retry safe |
| Cost | 1 LLM call | 1 call/platform + review (typically 1-2 rounds) |
The extra LLM calls buy correctness and actual delivery. For a single throwaway post the naive approach is cheaper; for anything you'll really publish, the review loop pays for itself the first time it catches an over-length draft before it fails at the platform.
Additional Considerations
- Media platforms. Instagram, TikTok, and YouTube require media on every post. Publora accepts up to 10 public
httpsURLs viamediaUrlsin the samecreate-postcall, or a draft -> upload -> schedule flow for local files. Extendpublishto passmediaUrlsand add those platforms toPLATFORM_SPECS. - Going live. Set
DRY_RUN = Falseand provide a futureschedule_time(ISO 8601 UTC). Publora rejects times more than a few minutes in the past. - Scheduling strategy.
schedule_timeis a single value here; a real deployment might compute per-platform optimal times or spread a thread. - Human-in-the-loop. LangGraph supports interrupts — add an approval checkpoint before
publishso a person signs off on the drafts. - Rate & plan limits.
create-postreturns structured403errors (POST_LIMIT_REACHED,SCHEDULE_HORIZON_REACHED, ...). Handle them to fail gracefully. - MCP alternative. The same actions are available over Publora's MCP server (
mcp.publora.com), so this agent can also be driven from Claude, Cursor, or any MCP client without the REST wrapper.
References
- Publora API documentation: https://docs.publora.com
- Create Post endpoint: https://docs.publora.com (see Endpoints -> Create Post)
- LangGraph documentation: https://langchain-ai.github.io/langgraph/
- Publora MCP server: https://mcp.publora.com
