Agent Playbook, build tutorial
A walkthrough of how to build a social media agent that turns one brief into a full posting plan: it plans the calendar, drafts each post adapted to its platform, suggests the visuals, and reports on what worked, while a person approves before anything publishes. Every code step is shown in three frameworks, Google ADK, the Claude SDK, and LangGraph, so you can build it on the stack your team already knows. The code here is the agent's core logic; standing it up in production also means adding the setup, integrations, and hardening around it.
Posting consistently is a grind. Someone has to think up topics, write a different version for each network, find or make a visual, schedule it, and then remember to look at how it did. When the week gets busy the calendar goes quiet, the voice drifts between whoever happened to post, and the same caption gets copied across platforms where it reads wrong on at least one of them.
A social media agent does the first pass. From one brief it lays out a calendar, drafts each post for its platform in your brand voice, suggests the visual, and pulls the numbers back in to learn what works, so your team reviews and approves instead of starting from a blank page every day. The one thing it never does is publish on its own. For long-form pieces behind the posts, this pairs with the content playbook, and for replies that land in your inbox, the email and inbox playbook.
You do not build the agent loop from scratch. A framework gives you the moving parts, and you supply the logic: a step is a model plus an instruction plus a typed schema or a few tools. The code tabs on the build steps show the same plan, draft, and report flow three ways.
Google ADK composes the steps with a SequentialAgent and a Runner, the Claude SDK drives a tool-use loop, and LangGraph builds an explicit graph. The brief intake, the scheduling, and the approval guardrail are deterministic and identical in all three; only the orchestration and the model calls differ.
Every campaign flows through one loop: the brief comes in and a calendar is planned, each post is drafted and given a visual, then scheduled. Nothing publishes until a person approves; once it does, the agent pulls metrics back in to inform the next plan. Guardrails for idempotency, brand safety, approval, and audit wrap the whole thing.
A campaign brief and a brand-voice profile, plus connected accounts for the networks you post to (Meta, LinkedIn, X) or a scheduler suite.
A model API key (Gemini, Claude, or both), your social accounts or scheduler, an approved asset library, and an image model only if you opt into generation.
Who approves posts, your brand-safety and disclosure rules, consent for any personal data, and which topics stay out of scope.
Install the packages and set your Gemini key. That is enough to run the runnable file below, which plans a short calendar and drafts posts for a sample brief locally. For production you also connect, with each platform's credentials, your social accounts or scheduler and your asset library.
# Python 3.10 or newer
python -m venv venv && source venv/bin/activate
# The runnable path below uses Google ADK + Gemini:
pip install google-adk google-genai pydantic
# Your Gemini key from aistudio.google.com/apikey:
export GEMINI_API_KEY="your-key-here" | Layer | Pick | Why |
|---|---|---|
| Brief and brand voice | A short campaign brief plus a brand-voice profile (tone, do and do-not words, examples) | Everything downstream starts from one input. The brief sets the goal, the audience, and the dates; the brand-voice profile keeps every draft sounding like you, not like a generic model. |
| Planner | A model (Gemini 2.5 Flash, Claude) over the brief | Turns the brief into a calendar: topics laid across dates and platforms, so a month of posting is planned in one pass instead of decided ad hoc each morning. The code tabs show this three ways. |
| Per-platform drafter | A model with the brand voice and per-network rules | Writes each post adapted to its platform, length, tone, and hashtags tuned for LinkedIn, Instagram, or X, because the same caption does not work everywhere. Drafts are for review, never published on their own. |
| Visuals | An image brief plus an asset library, or image generation if you opt in | By default the agent drafts the caption and writes an image brief or picks from your approved asset library. If you turn on image generation, it says so plainly and a person still reviews the result. |
| Scheduler and publishing | Meta Graph API, LinkedIn API, X API, or a suite (Buffer, Hootsuite) | Schedules the approved post to the right account at the right time. Publishing happens only after a human approves; the agent never auto-publishes. |
| Analytics and audit | Each platform's insights API plus an audit log | Pulls likes, reach, and clicks back in to learn what works, and records every plan, draft, approval, and publish so there is a trail behind each post. |
The plan, draft, and assemble steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The brief intake, visuals, scheduling, approval, and reporting steps are deterministic Python and identical everywhere. These are the building blocks, and they call your own systems (the social accounts, the asset library) by name; the complete file you can run today is in "Put it together" below.
Start with the input, not the cleverness. The agent reads a short campaign brief, the goal, the audience, the dates, the platforms, and a brand-voice profile that lists your tone, your do and do-not words, and a few example posts. It normalises both into one structured object that every later step reads from, and keys the run on a campaign id so a re-run never duplicates a plan. Deterministic, and the same in every framework.
from dataclasses import dataclass, field
@dataclass
class Campaign:
goal: str
audience: str
start: str # ISO date, e.g. "2026-07-01"
days: int
platforms: list[str] # ["linkedin", "instagram", "x"]
voice: str # tone + do/do-not words + examples
campaign_id: str = ""
def load_campaign(brief: dict, brand_voice: str) -> Campaign:
c = Campaign(goal=brief["goal"], audience=brief["audience"],
start=brief["start"], days=brief["days"],
platforms=brief["platforms"], voice=brand_voice,
campaign_id=brief["id"]) # id makes the run idempotent
return c
# One structured object feeds every later step; no model call needed here. A month of posting is a lot of small decisions, so the agent makes them in one pass. It asks the model to turn the brief into a calendar: a list of posts, each with a topic, a date, and a target platform, spread sensibly across the campaign window. A typed schema keeps the output as data the scheduler can use, not prose someone has to re-key. The tabs show the same planning in Gemini, Claude, and LangChain; the shape never changes.
from google import genai
from google.genai import types
from pydantic import BaseModel
client = genai.Client()
class Post(BaseModel):
topic: str; date: str; platform: str
class Calendar(BaseModel):
posts: list[Post]
def plan(c) -> Calendar:
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Plan {c.days} days of posts for {c.platforms} on goal "
f"'{c.goal}' for {c.audience}, starting {c.start}."],
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Calendar))
return res.parsed # a typed Calendar object from anthropic import Anthropic
client = Anthropic()
SCHEMA = {"type": "object", "properties": {
"posts": {"type": "array", "items": {"type": "object", "properties": {
"topic": {"type": "string"}, "date": {"type": "string"},
"platform": {"type": "string"}}, "required": ["topic", "date", "platform"]}}},
"required": ["posts"]}
def plan(c) -> dict:
res = client.messages.create(model="claude-opus-4-8", max_tokens=2048,
tools=[{"name": "save_calendar", "input_schema": SCHEMA}],
tool_choice={"type": "tool", "name": "save_calendar"},
messages=[{"role": "user", "content":
f"Plan {c.days} days of posts for {c.platforms} on goal "
f"'{c.goal}' for {c.audience}, starting {c.start}."}])
return res.content[0].input from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
class Post(BaseModel):
topic: str; date: str; platform: str
class Calendar(BaseModel):
posts: list[Post]
planner = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Calendar)
def plan(c) -> Calendar:
return planner.invoke(
f"Plan {c.days} days of posts for {c.platforms} on goal "
f"'{c.goal}' for {c.audience}, starting {c.start}.") The same caption does not work on every network, so the agent drafts each post for its platform: tighter and hashtag-light for LinkedIn, punchy with a few tags for Instagram, short and within the character limit for X. It writes in your brand voice using the profile from step 1. Each draft is a starting point for review, never something the agent publishes itself. The tabs show the same per-platform draft three ways.
from google import genai
client = genai.Client()
RULES = {"linkedin": "professional, 1 to 2 short paragraphs, 0 to 2 hashtags",
"instagram": "warm and visual, 3 to 8 relevant hashtags",
"x": "punchy, under 280 characters, 1 to 2 hashtags"}
def draft_post(topic: str, platform: str, voice: str) -> str:
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Brand voice: {voice}\nWrite a {platform} post about "
f"'{topic}'. Style: {RULES[platform]}. It is for review, "
f"not to be published automatically."]).text
# The return value is a draft shown to a person, never published by the agent. from anthropic import Anthropic
client = Anthropic()
RULES = {"linkedin": "professional, 1 to 2 short paragraphs, 0 to 2 hashtags",
"instagram": "warm and visual, 3 to 8 relevant hashtags",
"x": "punchy, under 280 characters, 1 to 2 hashtags"}
def draft_post(topic: str, platform: str, voice: str) -> str:
res = client.messages.create(model="claude-opus-4-8", max_tokens=1024,
messages=[{"role": "user", "content":
f"Brand voice: {voice}\nWrite a {platform} post about '{topic}'. "
f"Style: {RULES[platform]}. For review only, not to be published."}])
return res.content[0].text from langchain_google_genai import ChatGoogleGenerativeAI
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash") # or ChatAnthropic(...)
RULES = {"linkedin": "professional, 1 to 2 short paragraphs, 0 to 2 hashtags",
"instagram": "warm and visual, 3 to 8 relevant hashtags",
"x": "punchy, under 280 characters, 1 to 2 hashtags"}
def draft_post(topic: str, platform: str, voice: str) -> str:
return llm.invoke(
f"Brand voice: {voice}\nWrite a {platform} post about '{topic}'. "
f"Style: {RULES[platform]}. For review only, not to be published.").content Be plain about what this step does. By default the agent does not invent pictures: it writes an image brief describing what the visual should show, and it can pick a matching asset from your approved library by tag. If you choose to turn on image generation, the agent calls an image model and labels the output as generated, and a person still reviews it before it goes near a post. Either way, a human approves the visual. Deterministic, and the same in every framework.
def attach_visual(post: dict, asset_library, generate: bool = False) -> dict:
# Default: write an image brief and try the approved asset library by tag.
post["image_brief"] = f"Visual for: {post['topic']}, on-brand, {post['platform']}"
match = asset_library.find(tags=[post["topic"], post["platform"]])
if match:
post["image"] = match.url # reuse an approved asset
post["image_source"] = "library"
elif generate: # only if you opted in
post["image"] = image_model.create(post["image_brief"])
post["image_source"] = "generated" # labelled clearly for review
else:
post["image_source"] = "brief_only" # a person sources the image
return post
# Captions plus an image brief by default; generation only when you turn it on. Once a post is approved, scheduling is a plain API call. The agent posts the approved caption and visual to the right account at the planned time through the Meta Graph API, the LinkedIn API, the X API, or a suite like Buffer. It records the scheduled item against the campaign so the calendar stays the single source of truth. Scheduling an approved post is safe; the approval that gates it is the next step. Deterministic, and the same in every framework.
def schedule(post: dict, account) -> dict:
# post is already approved; this only places it on the calendar.
ref = scheduler.create(
account_id=account.id,
platform=post["platform"],
text=post["text"],
media=post.get("image"),
publish_at=post["date"]) # the planned time
post["scheduled_ref"] = ref
ledger.record(post, status="scheduled")
return post
# A queued, approved post; nothing is published until a person approves below. This is the guardrail that makes the whole thing safe for a brand. Drafts and scheduled items do not go live on their own; they sit in an approval queue where a person reads the post, sees the visual, and approves with one tap, edits, or discards. Only on approval does the agent publish. Nothing reaches your audience without a human behind it, which is the line that separates a helpful assistant from a brand-risk machine. Deterministic, and the same in every framework.
def queue_for_approval(post: dict) -> None:
approval_queue.add(post) # awaits a human; never auto-posts
def on_approval(post: dict, final_text: str) -> None:
publisher.publish(platform=post["platform"], text=final_text,
media=post.get("image")) # only after approval
ledger.record(post, status="published")
audit.log("published", post["id"], final_text)
# The agent drafts, schedules, and queues; publishing happens only on approval. After posts go live, the loop closes. The agent pulls likes, reach, comments, and clicks from each platform's insights API, attributes them back to the topic and the network, and summarises what is working so the next plan leans on it. Pulling and aggregating metrics is straight integration code against your own accounts, deterministic and the same in every framework. The summary it produces becomes context for the next planning pass.
def report(campaign_id: str) -> dict:
rows = []
for post in ledger.published(campaign_id):
m = insights.fetch(post["platform"], post["scheduled_ref"])
rows.append({"topic": post["topic"], "platform": post["platform"],
"reach": m["reach"], "likes": m["likes"],
"clicks": m.get("clicks", 0)})
summary = aggregate_by_topic(rows) # which topics and networks won
ledger.save_report(campaign_id, summary) # context for the next plan
return summary
# Metrics pulled per post, aggregated by topic, and fed into the next calendar. The pieces run on each campaign. ADK composes plan, draft, and report under a SequentialAgent and a Runner, the Claude SDK runs them in a tool-use loop, and LangGraph builds an explicit plan, draft, schedule graph. Whichever you pick, the same guardrails apply: the campaign id makes every run idempotent, the agent never publishes without approval, an on-brand check screens every draft, posting respects each platform's rate limits, and every plan, draft, approval, and publish is written to an immutable audit log.
from google.adk.agents import SequentialAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
social_pipeline = SequentialAgent(name="social_pipeline",
sub_agents=[planner, drafter, reporter]) # each an LlmAgent with the tools
runner = Runner(agent=social_pipeline, app_name="social",
session_service=InMemorySessionService())
def guard(post: dict) -> str | None:
if store.seen(post["campaign_id"]): return "already_planned" # idempotent
if not on_brand(post["text"]): return "needs_review" # brand check
if rate_limited(post["platform"]): return "wait" # rate limit
return None
# Run per campaign via cron / Cloud Scheduler. Publishing always needs approval. from anthropic import Anthropic
client = Anthropic()
TOOLS = [plan_tool, draft_tool, schedule_tool, queue_tool] # JSON schemas
def run_social(messages: list):
while True:
resp = client.messages.create(model="claude-opus-4-8", max_tokens=2048,
tools=TOOLS, messages=messages)
if resp.stop_reason != "tool_use":
return resp
messages.append({"role": "assistant", "content": resp.content})
for b in resp.content:
if b.type == "tool_use":
out = dispatch(b.name, b.input) # no publish tool: drafts only
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": b.id, "content": str(out)}]}) from langgraph.graph import StateGraph, START, END
g = StateGraph(dict)
g.add_node("plan", lambda s: {"calendar": plan(s["campaign"])})
g.add_node("draft", lambda s: {"posts": [draft_post(p.topic, p.platform,
s["campaign"].voice) for p in s["calendar"].posts]})
g.add_node("schedule", lambda s: {"queued": [queue_for_approval(p) for p in s["posts"]]})
g.add_edge(START, "plan")
g.add_edge("plan", "draft")
g.add_edge("draft", "schedule")
g.add_edge("schedule", END)
social_pipeline = g.compile() # invoke per campaign; posts queue for approval
Here is a runnable file. It plans a short calendar from a sample brief,
drafts a post per platform, and writes an image brief for each, and it does
not publish anything. The brief is inline text; production reads it from
your campaign source, as in the build steps. Save it as main.py and run
python main.py.
# main.py -- run: python main.py
# A runnable reference: plans a short calendar from a sample brief, drafts one
# post per platform, and suggests an image brief. It does NOT publish anything
# (publishing always needs approval). The brief is inline; production reads it
# from your campaign source, as in the build steps.
import asyncio
from google import genai
from google.genai import types
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
client = genai.Client() # reads GEMINI_API_KEY
# ---- a sample brief (replace with a real campaign brief) ----
SAMPLE_BRIEF = """Goal: announce our new GST invoicing feature for small shops.
Audience: Indian SME owners and their accountants.
Dates: 3 days starting 2026-07-01. Platforms: linkedin, instagram, x.
Brand voice: warm, plain, practical. Avoid hype words like 'revolutionary'."""
# -------------------------------------------------------------
RULES = {"linkedin": "professional, 1 to 2 short paragraphs, 0 to 2 hashtags",
"instagram": "warm and visual, 3 to 8 relevant hashtags",
"x": "punchy, under 280 characters, 1 to 2 hashtags"}
def plan_calendar(brief: str) -> str:
"""Turn the brief into a calendar as JSON (a list of posts)."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["Plan a posting calendar from this brief as JSON with key "
"posts (list of {topic, date, platform}).\n" + brief]).text
def draft_post(topic: str, platform: str, voice: str) -> str:
"""Draft one on-brand post for a platform. For review only; not published."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Brand voice: {voice}\nWrite a {platform} post about "
f"'{topic}'. Style: {RULES.get(platform, 'concise')}. It is "
f"for review, not to be published automatically."]).text
def suggest_visual(topic: str, platform: str) -> str:
"""Write an image brief for a person or asset library to source from."""
return f"Image brief for {platform}: an on-brand visual showing '{topic}'."
agent = LlmAgent(
name="social_agent", model="gemini-2.5-flash",
instruction=("Call plan_calendar on the brief, then for two of the planned "
"posts call draft_post and suggest_visual. Report the calendar, "
"the drafts, and the image briefs. Make clear every post is "
"queued for approval and nothing is published."),
tools=[plan_calendar, draft_post, suggest_visual])
async def main():
runner = InMemoryRunner(agent=agent, app_name="social")
session = await runner.session_service.create_session(app_name="social", user_id="demo")
msg = types.Content(role="user",
parts=[types.Part(text=f"Plan and draft for this brief:\n{SAMPLE_BRIEF}")])
async for event in runner.run_async(user_id="demo", session_id=session.id, new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
if __name__ == "__main__":
asyncio.run(main()) The agent reports a small calendar of topics across the three platforms, two drafted posts adapted to their networks, and an image brief for each, all queued for approval. The exact wording is model-generated, so it varies run to run; publishing is never automatic.
A person sees one screen: the planned calendar, the drafted post and its suggested visual, and a single approve action before anything publishes.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You want your own brand voice, planning rules, and account wiring, with approval and the data in-house | High | Build cost + API and model usage | Full |
| Scheduler suite (Buffer, Hootsuite, Sprout Social) | Planning, scheduling, and reporting in one tool, fast start, you accept their drafting and rules | Low | Subscription per seat or channel | Medium |
| No-code (n8n or Make with social nodes) | A few simple flows, low volume, a quick pilot | Low | Cheap, but brittle as the brand rules grow | Low |
The defining safety choice is that the agent never auto-publishes. Planning, drafting, and suggesting visuals are low-risk and reversible; a public post is not, so it always waits for a person who reads the caption, sees the visual, and approves. Every draft is screened by an on-brand check first, so what reaches the queue already fits your voice and your do-not list, and a reviewer makes the final call.
Beyond approval, the agent follows each platform's policies and discloses ads and paid partnerships as those platforms require, and it does not post anyone's personal data without consent, in line with the DPDP Act 2023, whose core obligations phase in through 2027. Sensitive or off-brief topics stay out of scope, and every plan, draft, approval, and publish is written to an immutable audit log so there is a record behind each post.
These are directional, not promises, and depend on how much you post and how much editing the drafts need. The point is the shape of the change: less time planning and writing, a steadier cadence, a better per-platform fit, and a feedback loop that was rarely closed by hand.
| Metric | Manual | Agent |
|---|---|---|
| Time to plan a month | A long planning session, then drafting each post one at a time as the date nears | A full calendar drafted from one brief in a single pass, so the team reviews instead of starting from blank |
| Consistency of posting | Gaps when the week gets busy, and tone that drifts between people | A steady cadence with on-brand drafts every time, so the calendar stays full and the voice stays steady |
| Per-platform fit | The same caption copied across networks, so it reads wrong somewhere | Each post adapted to its platform, length, tone, and hashtags, drafted for that network |
| Learning from results | Engagement is glanced at, rarely fed back into the next plan | Metrics pulled in and summarised, so the next calendar leans on what actually worked |
Time saved depends on posting volume and how much the drafts need editing. Figures are ranges that vary by team, not guarantees.
Everything you need to know about the service and how it works. Can’t find an answer? Mail us at info@galific.com
Galific designs, builds, and runs social media agents like this on ADK, the Claude SDK, or LangGraph, wired into your accounts and your brand voice, with a person approving before anything publishes. Or explore the ready-made versions in our agent suite.