LangGraph Social Media Agent: Draft, Guard, Publish
Most content agent graphs stop at the draft. The model writes a caption, a human pastes it into a scheduler, and the last mile stays manual forever. Publishing is the node those graphs are missing, and it is where the guardrails belong, because it is the only node holding an API key.
In short:
- Publishing is the missing node. The draft is not the deliverable, the queued post is.
- The guardrail belongs in its own node between the model and the publish tool, where a caption that breaks a platform rule or repeats a past post gets caught before the API sees it.
- Graph state is what makes publishing safe: the post id the API returns, read back with GET /posts/:id, plus an Idempotency-Key on every retry.
Publishing is the node a writing agent never gets
A publishing node is where an agent’s output enters the world, so ownership, limits and duplicates get decided there. A graph that ends at the draft hands every one of those decisions to a human, every day.
Drafting agents are common because drafting is safe: the human catches the bad caption in review. Publishing changes the risk profile of the whole graph. The moment a tool node can create a post, the agent can also create the wrong post twice on the wrong channel, with a claim your legal page will not back.
Two ways to wire it: your own REST tool, or the MCP adapter
You can hand the agent a @tool that calls the public REST API, or attach langchain.mcp.MCPAdapter and let it discover tools from a server. Both routes end in the same publish call.
The REST route gives the agent a stable contract. One @tool wraps POST https://api.postsider.com/public/v1/posts, with an explicit body: type (now, schedule or draft), a date in ISO 8601 UTC, shortLink, tags, and a posts[] array where each entry carries integration.id and a value[] array of content plus image. Send image as an array even when it is empty. X channels also accept settings.who_can_reply_post, which keeps the agent out of reply fights. A successful create returns 201 Created. The reference is at docs.postsider.com/api, and the tradeoffs are in MCP vs REST vs SDK for social media.
The MCP route is lighter to wire and heavier to reason about. MCPAdapter takes an MCP server config shaped like {"mcpServers": {"<name>": {"command": ..., "args": [...], "env": {...}}}} or a Path to a server script. It exposes list_tools, and the adapted tools are invoked asynchronously, so the agent gets whatever the server publishes without a wrapper per endpoint. The MCP server ships with the product, documented at docs.postsider.com/agent/mcp. I tested the adapter against a local FastMCP stdio server rather than PostSider’s own server, so the protocol behavior is proven and the tool names are whatever list_tools returns at connect time. The protocol mechanics are in driving social media from an AI agent with MCP.
Pick one route and keep the other thin.
The minimal graph: an agent node, a guard node, one ToolNode
A working publishing agent is a StateGraph with a model node, a guardrail node and a ToolNode, plus a conditional edge back to the model. The versions below are what I ran: langgraph 1.2.11, langgraph-checkpoint 4.2.0, langchain-core 1.6.3, langchain 1.4.2, langchain-openai 1.6.2. LangGraph needs Python 3.10 or newer.
Graph state is the prebuilt MessagesState, extended with two lists. The agent node binds tools to the model and returns its message. ToolNode runs whatever the model asked for, and the conditional edge decides whether the turn ends or another tool call is coming.
One trap before you copy an older tutorial. In an explicit add_conditional_edges map, the key for the end of the graph has to be END (the string "__end__"), not "end". A map written as {"tools": "tools", "end": END} raises KeyError: '__end__' at runtime, because the router returns "__end__" and the map has no such key. tools_condition avoids the whole problem when you leave the map out.
import hashlib, json, os
import requests
from langchain.chat_models import init_chat_model
from langchain.messages import ToolMessage
from langchain.tools import tool
from langgraph.graph import START, END, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
API = "https://api.postsider.com/public/v1"
HEADERS = {"Authorization": os.environ["POSTSIDER_API_KEY"], "Content-Type": "application/json"}
LIMITS = json.loads(os.environ["PLATFORM_LIMITS"]) # see /blog/character-limits-every-network
BANNED = ["guaranteed", "risk free", "overnight results"]
model = init_chat_model(os.environ["AGENT_MODEL"])
class SocialState(MessagesState):
published: list[str] # post ids the API returned
seen: list[str] # content hashes already accepted
@tool
def postsider_create_post(post_type: str, date: str, posts: list, tags: list | None = None):
"""Create, schedule or publish a post. post_type is 'now', 'schedule' or 'draft'.
Each entry in posts carries integration_id, platform and content."""
body = {"type": post_type, "date": date, "shortLink": True, "tags": tags or [],
"posts": [{"integration": {"id": p["integration_id"]},
"value": [{"content": p["content"], "image": []}]} for p in posts]}
headers = dict(HEADERS)
# Same body, same key: a retry cannot turn into a second post.
headers["Idempotency-Key"] = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
r = requests.post(f"{API}/posts", json=body, headers=headers, timeout=30)
if r.status_code == 429:
raise RuntimeError(f"rate limited, Retry-After {r.headers.get('Retry-After')}")
r.raise_for_status()
return r.text # 201 Created
@tool
def postsider_get_post(post_id: str):
"""Read a post back so the agent knows what state it is really in."""
r = requests.get(f"{API}/posts/{post_id}", headers=HEADERS, timeout=30)
r.raise_for_status()
return r.text
tools = [postsider_create_post, postsider_get_post]
bound = model.bind_tools(tools)
tool_node = ToolNode(tools)
def agent(state):
return {"messages": [bound.invoke(state["messages"])]}
def guard(state):
"""Check every publish call while it is still a tool call."""
msg = state["messages"][-1]
seen = list(state.get("seen", []))
for call in getattr(msg, "tool_calls", []):
if call["name"] != "postsider_create_post":
continue
for post in call["args"]["posts"]:
content, platform = post["content"], post["platform"]
limit = LIMITS.get(platform)
if limit and len(content) > limit:
return {"messages": [ToolMessage(
content=f"{len(content)} chars, over the {platform} limit",
tool_call_id=call["id"])]}
if any(w in content.lower() for w in BANNED):
return {"messages": [ToolMessage(
content="blocked claim in the copy", tool_call_id=call["id"])]}
digest = hashlib.sha256(content.encode()).hexdigest()
if digest in seen:
return {"messages": [ToolMessage(
content="this copy was already queued", tool_call_id=call["id"])]}
seen.append(digest)
return {"seen": seen}
def publish(state):
"""Run the tools, then keep the ids they returned in state."""
out = tool_node.invoke(state)
ids = list(state.get("published", []))
for msg in out["messages"]:
if msg.name != "postsider_create_post":
continue
for item in json.loads(msg.content):
if item.get("postId"):
ids.append(item["postId"])
return {"messages": out["messages"], "published": ids}
def route(state):
last = state["messages"][-1]
if isinstance(last, ToolMessage):
return "agent" # the guard rejected it and said why
if getattr(last, "tool_calls", []):
return "tools"
return "__end__"
graph = StateGraph(SocialState)
graph.add_node("agent", agent)
graph.add_node("guard", guard)
graph.add_node("tools", publish)
graph.add_edge(START, "agent")
graph.add_edge("agent", "guard")
graph.add_conditional_edges("guard", route, {"tools": "tools", "agent": "agent", END: END})
graph.add_edge("tools", "agent")
app = graph.compile()
Set AGENT_MODEL to any provider:model string your installed SDK accepts, export PLATFORM_LIMITS as a JSON map of platform to character count, then invoke with {"messages": [...], "published": [], "seen": []}.
The guardrail node runs before the tool, not after
Validate a publish call while it is still a tool call. Once the API answers 201 the post is queued, and undoing that costs more than blocking it.
The guard node does three jobs, each answering a question the model cannot answer about itself. The length check reads the limit for the target platform from a table you maintain, so the agent never guesses a number; the maintained per-platform table lives in character limits every network. The claims check runs copy against phrases your product cannot put in writing. The duplicate check hashes content and compares it with everything already accepted in this run, which stops one thought going out twice.
When a check fails, the node returns a ToolMessage addressed to the failing call id. That message lands where a tool result would land, so the model reads the reason, rewrites the caption, and calls the tool again. No exception, no dead graph. That repair loop is why the guard is a node instead of a wrapper function. Keep it deterministic: the model can suggest a rewrite, but only your code decides whether the post goes through.
State is how the agent knows what it already published
A tool call is not a published post. Store the id the API returned, keep the content hashes you already sent, and read the post back with GET /posts/:id before the agent claims anything worked.
The publish node wraps ToolNode, parses the response body, and appends each postId to state["published"]. The agent then holds a factual record of its own output instead of a memory of having called a function. The read-back tool closes the loop for the cases that matter later: a retry, or a question about status the next morning. If the id is not in the read-back response, nothing was published, and the agent should say so.
The idempotency key makes the retry safe. postsider_create_post derives the key from a hash of the request body, so the same content sent twice carries the same key and the second attempt returns the original post. Network timeouts are where this earns its keep, because the request can succeed before the socket dies, and a naive retry publishes twice. The pattern is in idempotency for social posting, since the failure mode stays invisible until it is public.
Two smaller details belong in the same state design. Drafts still need a date, so a graph that only drafts cannot skip that field.
Rate limits and pacing: 60 per minute, Retry-After, the next free slot
The public API allows 60 requests per minute per organization. A 429 carries Retry-After, and GET /find-slot/:id returns the next free queue slot when you would rather fill the calendar than retry in a loop.
An agent that publishes in a loop hits that ceiling faster than you expect, because one logical post fans out across channels and each create is a request. Honor Retry-After, and pace the batch so the graph writes drafts and lets the queue do the timing. Ask GET /find-slot/:id for the next free slot and schedule against it, which spreads work across your own calendar instead of the wall clock. What does not work is an unbounded retry loop with a fixed sleep, which burns the minute budget on requests that were already going to fail. See schedule posts with a social media API for the calendar half.
A rate limit error is also a signal about the graph: an agent that drafts five posts a day should not be near 60 per minute.
langchain.mcp is beta, so keep a second path
MCPAdapter works today and raises a LangChainBetaWarning that says the API can change. Treat the adapter as a convenience layer, not as a load-bearing dependency.
In practice that is specific. Put every langchain.mcp import behind one module, so a rename touches a single file. Keep the REST @tool wired and tested as the fallback, because it depends on the HTTP contract rather than on a beta Python API. Pin langchain and langchain-core to the versions you shipped with, and pin the MCP server version you connected to. Re-run the graph against a staging channel after any bump, including a patch bump.
None of that argues against the MCP route, only against giving the agent exactly one way to publish. Both routes end at POST /posts, so swapping them changes the wiring and leaves the guard node, the state fields and the idempotency key alone.
A small graph that ships
The whole thing is one model node, one guardrail node, one tool node and two lists in state. That is the difference between an agent that writes posts and one that publishes them, and the guardrail is what makes the second one safe to leave running.
PostSider is the product I built for that loop: the API the graph calls, the queue the drafts land in, and the review screen for the days you want to look first. It starts with an API key and a connected channel. Plans are on /pricing, and the setup in this post is one tool plus one node away.
Lukasz Blania is the founder of PostSider. He builds publishing infrastructure for social teams and runs his own accounts through agent graphs like the one above.
Frequently asked questions
Can a LangGraph agent publish to social media on its own?
Yes. Wrap the PostSider REST endpoint in a tool, bind it to the model with bind_tools, and route the model's tool calls through a guardrail node before ToolNode executes anything.
Why does my LangGraph conditional edge raise KeyError: '__end__'?
Because the explicit edge map used the string 'end' as a key. The key has to be END or '__end__'. Leaving the map out also works, since tools_condition supplies its own default.
Is langchain.mcp stable enough for production?
It is beta and raises a LangChainBetaWarning that says the API can change. It works, including against a local stdio server, so keep the REST tool behind it as the fallback path.
How do I stop an agent from posting the same content twice?
Send an Idempotency-Key header with every create request, keep the returned post id in graph state, and read the post back with GET /posts/:id before the agent reports success.
What is the PostSider API rate limit?
60 requests per minute per organization. A 429 response carries Retry-After, and GET /find-slot/:id returns the next free queue slot when you would rather pace posts than retry.