← All posts
Tutorials

Building an AI Social Media Agent with LangGraph

A full walkthrough for building a LangGraph agent that decides what to post and publishes it through a social media REST API, with runnable Python code.

Zakir Hossen profile imageZakir Hossen··7 min read

Building an AI Social Media Agent with LangGraph

LangGraph gives you explicit control over an agent's decision path: a state machine of nodes and edges instead of a single opaque agent loop. That control matters for social posting agents, because you usually want more than "call an LLM, call a tool, done." You want a draft step, a review step, and a publish step that only fires when the draft passes review.

This tutorial builds exactly that: a LangGraph agent that decides what to post, drafts the copy, checks it against basic rules, and calls a social media REST API to publish. The code is runnable as written, adjusted for your own API key.

If you haven't compared REST against MCP tool calling for this kind of agent, read Social Media API for AI Agents: MCP vs REST in 2026 first — this tutorial uses the REST path. For the concept of a single API across platforms, see What Is a Unified Social Media API?.

#What We're Building

A graph with four nodes:

  1. decide — an LLM node that looks at input (a topic, an event, a piece of content) and decides whether a post is warranted and what angle to take.
  2. draft — an LLM node that writes the actual post text.
  3. review — a rule-based node that checks length, banned words, and required disclosures.
  4. publish — a tool node that calls the social posting API.

The graph loops back to draft if review fails, up to a retry limit, instead of publishing something broken.

#Install Dependencies

 1pip install langgraph langchain-anthropic httpx

#Define the State

LangGraph passes a typed state object between nodes. Define exactly what the graph needs to carry.

 1from typing import TypedDict, Optional
 2
 3class AgentState(TypedDict):
 4    topic: str
 5    should_post: bool
 6    draft_text: Optional[str]
 7    review_passed: bool
 8    review_feedback: Optional[str]
 9    retry_count: int
10    platforms: list[str]
11    profile_id: str
12    published_post_id: Optional[str]

#The Decide Node

 1from langchain_anthropic import ChatAnthropic
 2
 3llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0)
 4
 5def decide_node(state: AgentState) -> AgentState:
 6    prompt = f"""You are deciding whether the following topic is worth a
 7social media post right now: "{state['topic']}"
 8
 9Reply with only YES or NO."""
10    response = llm.invoke(prompt)
11    should_post = "YES" in response.content.upper()
12    return {**state, "should_post": should_post}

#The Draft Node

 1def draft_node(state: AgentState) -> AgentState:
 2    feedback_note = ""
 3    if state.get("review_feedback"):
 4        feedback_note = f"\n\nPrevious draft was rejected for: {state['review_feedback']}. Fix this."
 5
 6    prompt = f"""Write a social media post about: {state['topic']}
 7Keep it under 280 characters. No hashtags. No emoji spam.{feedback_note}"""
 8    response = llm.invoke(prompt)
 9    return {**state, "draft_text": response.content.strip()}

#The Review Node

This one is plain Python, no LLM call, because deterministic rules should not depend on a model's mood.

 1BANNED_WORDS = ["guaranteed", "risk-free", "act now"]
 2
 3def review_node(state: AgentState) -> AgentState:
 4    text = state["draft_text"] or ""
 5    issues = []
 6
 7    if len(text) > 280:
 8        issues.append(f"too long ({len(text)} chars)")
 9    if any(word in text.lower() for word in BANNED_WORDS):
10        issues.append("contains banned promotional language")
11    if not text.strip():
12        issues.append("empty draft")
13
14    passed = len(issues) == 0
15    return {
16        **state,
17        "review_passed": passed,
18        "review_feedback": "; ".join(issues) if issues else None,
19        "retry_count": state.get("retry_count", 0) + (0 if passed else 1),
20    }

#The Publish Node — the Actual Tool Call

This is the part that matters for this blog: the node that turns a reviewed draft into a live post via Schedule & Chill's REST API.

 1import httpx
 2import os
 3
 4API_KEY = os.environ["SCHEDULENCHILL_API_KEY"]
 5
 6def publish_node(state: AgentState) -> AgentState:
 7    response = httpx.post(
 8        "https://api.schedulenchill.com/v1/posts",
 9        headers={"Authorization": f"Bearer {API_KEY}"},
10        json={
11            "profile_ids": [state["profile_id"]],
12            "text": state["draft_text"],
13            "platforms": state["platforms"],
14            "when": "now",
15        },
16        timeout=30,
17    )
18    response.raise_for_status()
19    result = response.json()
20    return {**state, "published_post_id": result["id"]}

Schedule & Chill is free right now — no credit card, no trial timer — with a 2-connected-account, 500-post-per-month limit on the free plan, so you can build and run this end to end without a billing step in the way.

#Wiring the Graph

 1from langgraph.graph import StateGraph, END
 2
 3def route_after_decide(state: AgentState) -> str:
 4    return "draft" if state["should_post"] else END
 5
 6def route_after_review(state: AgentState) -> str:
 7    if state["review_passed"]:
 8        return "publish"
 9    if state.get("retry_count", 0) >= 3:
10        return END  # give up rather than loop forever
11    return "draft"
12
13graph = StateGraph(AgentState)
14graph.add_node("decide", decide_node)
15graph.add_node("draft", draft_node)
16graph.add_node("review", review_node)
17graph.add_node("publish", publish_node)
18
19graph.set_entry_point("decide")
20graph.add_conditional_edges("decide", route_after_decide, {"draft": "draft", END: END})
21graph.add_edge("draft", "review")
22graph.add_conditional_edges("review", route_after_review, {"publish": "publish", "draft": "draft", END: END})
23graph.add_edge("publish", END)
24
25app = graph.compile()

#Running It

 1result = app.invoke({
 2    "topic": "We just shipped IndexNow support for faster indexing",
 3    "should_post": False,
 4    "draft_text": None,
 5    "review_passed": False,
 6    "review_feedback": None,
 7    "retry_count": 0,
 8    "platforms": ["x", "linkedin"],
 9    "profile_id": "prof_abc123",
10    "published_post_id": None,
11})
12
13print(result.get("published_post_id"))

If decide says no, the graph ends without calling draft at all. If review keeps failing, it ends after three attempts rather than publishing something that never passed the check.

#Handling Media

If your agent needs to attach an image, upload it first and pass the returned media ID:

 1def upload_media(file_path: str) -> str:
 2    with open(file_path, "rb") as f:
 3        response = httpx.post(
 4            "https://api.schedulenchill.com/v1/media",
 5            headers={"Authorization": f"Bearer {API_KEY}"},
 6            files={"file": f},
 7            timeout=60,
 8        )
 9    response.raise_for_status()
10    return response.json()["id"]

Then add "media_ids": [media_id] to the publish_node payload. Keep uploads under the free plan's 400MB limit, and be aware the whole file is buffered client-side in this simple example — for large video files, stream it instead of loading it into memory.

#Why LangGraph and Not a Plain Agent Loop

A single ReAct-style agent loop would let the LLM decide, draft, and publish in one uninterrupted pass, with no gate between drafting and publishing. That is the failure mode you want to avoid for anything that goes out under your brand: LangGraph's explicit graph shape means the review node is a real, un-skippable checkpoint, not a step the LLM can talk itself past.

For agents that also need to look up existing brand assets before posting, add a node that calls the media library search endpoint before draft, and pass the result into the drafting prompt. See How to Automate Social Media Posting with AI for the broader automation patterns this fits into.

If you'd rather skip building the REST wiring yourself, Schedule & Chill also ships an MCP server with 21 tools that a LangGraph agent can call via a tool-calling wrapper — covered in the MCP vs REST post.

#Frequently Asked Questions

Does LangGraph require LangChain? LangGraph is built by the LangChain team and integrates cleanly with LangChain models and tools, but the graph and state logic itself has no hard dependency on LangChain's agent abstractions — you can use plain Python functions as nodes, as this tutorial does.

Can I run this on a schedule instead of on demand? Yes. Wrap app.invoke(...) in a scheduled job (cron, a queue worker, or a serverless function trigger) and pass a fresh topic each run. See How to Build a Social Media Scheduler with a REST API for the scheduling side of this.

What happens if the API call in publish_node fails? response.raise_for_status() raises an exception, which stops the graph. Wrap it in a try/except and route to a retry or alert node if you need production-grade error handling.

Can this post to more than one platform at once? Yes — the platforms list in the publish payload accepts multiple values (x, linkedin, facebook, and others depending on which accounts are connected), and the API fans the single post out to each.

Try it in a minute.Free, no card. One URL into your AI tool.
Start free