Agent Playbook, build tutorial
A walkthrough of how to build a content agent that takes a brief to a researched, on-brand first draft: it gathers real sources and checks each link resolves, writes in your voice with inline citations, and adds internal links from your own sitemap, while a person edits and approves before anything is published. 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.
Most of the time a piece of content takes is not the writing, it is everything around it: finding sources, checking they are still live, building an outline, remembering to link to your own pages, and keeping the voice consistent. Do that by hand for every brief and the queue backs up, citations get skipped, and the draft reads like whoever was free that day.
A content agent does the heavy first pass. It researches with real sources it has checked resolve, drafts on-brand with the citations in place, and suggests internal links from your sitemap, so a writer starts from a strong, sourced draft rather than a blank page. The one thing it never does is publish on its own: a person always edits and approves. For getting that draft seen and shared, this pairs with the social-media 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 tool, a search tool for research, a typed schema for the outline. The code tabs on the build steps show the same research, outline, and draft 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 source verification, the internal linking, the SEO checks, and the approval guardrail are deterministic and identical in all three; only the orchestration and the model calls differ.
Every brief flows through one loop: read in, researched with sources verified, outlined, then drafted on-brand with citations. Internal links and SEO checks are added, and the draft goes to a person to edit and approve before it reaches the CMS. Guardrails for source verification, idempotency, and audit wrap the whole thing.
A brief format (topic, audience, angle, keyword, length), your style guide, and your sitemap so internal links point at real pages.
A model API key (Gemini, Claude, or both), a search or retrieval tool for research, and your CMS for the human-approved draft to land in.
Who edits and approves, your originality and citation rules, your AI-assistance disclosure policy, and DPDP Act 2023 handling for any personal data in briefs.
Install the packages and set your Gemini key. That is enough to run the runnable file below, which researches a sample brief with live web search, verifies the sources, and drafts an outline locally. For production you also connect your search or retrieval tool, your sitemap, and the CMS the approved draft lands in.
# 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 httpx pydantic
# Your Gemini key from aistudio.google.com/apikey:
export GEMINI_API_KEY="your-key-here" | Layer | Pick | Why |
|---|---|---|
| Brief and style-guide intake | A typed brief plus your style guide (tone, banned words, reading level) | Every draft starts from the same inputs: the topic, the audience, the angle, and the rules your brand writes by. Deterministic, so two runs of the same brief begin the same way. |
| Research and sources | A model with a real search or retrieval tool (Google Search grounding, Claude web search, Tavily) | Gathers facts from the live web and your own documents, and keeps the source URL with every claim. The code tabs show this three ways. The agent later verifies each URL actually resolves. |
| Outline | A model over the brief and the gathered research | Turns the brief and the verified sources into a heading structure a writer would recognise, so the draft has a spine instead of wandering. |
| Drafting | A model with your voice, the outline, and inline citations | Writes the draft on-brand, section by section, citing the source next to each claim that needs one. The draft is for a person to edit, never to publish on its own. |
| Internal linking and SEO | Deterministic Python over your sitemap and a structure checker | Matches keywords to your own URLs to add internal links, then checks headings, meta, length, and readability against your rules. Both are rules, not guesses. |
| Human review and data | An approval step, a draft store, and an audit log | Holds the brief, the sources, the draft, and the originality check, and records every run, so a person edits and approves before anything is published. |
The research, draft, and assembly steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The brief intake, the internal linking, the SEO checks, and the approval step are deterministic Python and identical everywhere. These are the building blocks, and they call your own systems (the search tool, your sitemap, the CMS) by name; the complete file you can run today is in "Put it together" below.
Start with the inputs, not the cleverness. The agent reads a typed brief, the topic, the audience, the angle, the target keyword, and a desired length, alongside your style guide: tone, banned words, and reading level. It keys the run on the brief id so the pipeline is idempotent, the same brief is processed once even if the job re-runs. Deterministic, and the same in every framework.
from dataclasses import dataclass, field
@dataclass
class Brief:
id: str
topic: str
audience: str
angle: str
keyword: str
words: int = 1200
style: dict = field(default_factory=dict) # tone, banned words, reading level
def load_brief(raw: dict, seen: set) -> Brief | None:
if raw["id"] in seen: # already drafted
return None
seen.add(raw["id"])
return Brief(**raw)
# The brief id makes it idempotent: each brief is drafted once, even on a re-run. A draft is only as good as what it is built on, so before any writing the agent researches the topic with a real search or retrieval tool and keeps the source URL with every fact it gathers. It then verifies each URL actually resolves with a plain HTTP check, because a citation that 404s is worse than none. Nothing is invented here: a claim with no source that resolves does not make it into the brief. The tabs show research three ways, Gemini with Google Search grounding, Claude with its web search tool, and LangChain with a search tool.
from google import genai
from google.genai import types
import httpx
client = genai.Client()
def research(topic: str) -> dict:
# Google Search grounding: the model searches the live web for real sources.
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Research '{topic}'. Return key facts with the source URL for each."],
config=types.GenerateContentConfig(
tools=[types.Tool(google_search=types.GoogleSearch())]))
urls = [c.web.uri for c in
res.candidates[0].grounding_metadata.grounding_chunks if c.web]
return {"notes": res.text, "sources": verify(urls)}
def verify(urls: list[str]) -> list[str]:
# A cited URL that does not resolve is dropped, never kept.
return [u for u in urls if _ok(u)]
def _ok(u: str) -> bool:
try: return httpx.head(u, follow_redirects=True, timeout=8).status_code < 400
except Exception: return False from anthropic import Anthropic
import httpx
client = Anthropic()
def research(topic: str) -> dict:
# The server-side web_search tool grounds the model in real, live results.
res = client.messages.create(model="claude-opus-4-8", max_tokens=2048,
tools=[{"type": "web_search_20250305", "name": "web_search",
"max_uses": 5}],
messages=[{"role": "user", "content":
f"Research '{topic}'. List key facts, each with its source URL."}])
text = "".join(b.text for b in res.content if b.type == "text")
urls = [b.url for b in res.content
if getattr(b, "type", "") == "web_search_result"]
return {"notes": text, "sources": [u for u in urls if _ok(u)]}
def _ok(u: str) -> bool:
try: return httpx.head(u, follow_redirects=True, timeout=8).status_code < 400
except Exception: return False from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_community.tools.tavily_search import TavilySearchResults
import httpx
search = TavilySearchResults(max_results=6) # a real web-search tool
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash")
def research(topic: str) -> dict:
hits = search.invoke(topic) # [{content, url}, ...]
sources = [h["url"] for h in hits if _ok(h["url"])]
notes = llm.invoke(
f"Summarise key facts about '{topic}' from: {hits}").content
return {"notes": notes, "sources": sources}
def _ok(u: str) -> bool:
try: return httpx.head(u, follow_redirects=True, timeout=8).status_code < 400
except Exception: return False With verified research in hand, the next move is structure. The agent calls the model once to turn the brief and the gathered facts into a heading outline, an introduction, a handful of sections that each map to something the research supports, and a conclusion. A structured outline keeps the draft from wandering and gives the writer a spine to edit against. This is a single, plain model call, the same in every framework.
from google import genai
from google.genai import types
from pydantic import BaseModel
client = genai.Client()
class Outline(BaseModel):
title: str
sections: list[str] # ordered H2 headings
def outline(brief, research: dict) -> Outline:
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Topic: {brief.topic}. Angle: {brief.angle}. "
f"Audience: {brief.audience}. Research notes:\n{research['notes']}\n"
f"Return a title and ordered H2 headings for a {brief.words}-word piece."],
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Outline))
return res.parsed # a typed Outline the drafter writes against Now the writing. The model drafts section by section against the outline, in your voice and within your style guide, and it cites a source next to each claim that needs one, drawing only from the verified sources gathered earlier. This is the one place to be clear: the agent drafts, it does not publish. Every draft goes to a person to edit and approve. The tabs show the same on-brand draft three ways.
from google import genai
client = genai.Client()
def draft(brief, outline, sources: list[str]) -> str:
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Write a {brief.words}-word draft for the outline below in this "
f"voice: {brief.style}. Cite a source inline, as [n], next to each "
f"claim, using ONLY these sources: {sources}. Do not invent sources. "
f"This is a draft for human review, not for publishing.\n{outline}"]).text
# The return value is a draft a person edits and approves; the agent never publishes. from anthropic import Anthropic
client = Anthropic()
def draft(brief, outline, sources: list[str]) -> str:
res = client.messages.create(model="claude-opus-4-8", max_tokens=4096,
messages=[{"role": "user", "content":
f"Write a {brief.words}-word draft for this outline in the voice {brief.style}. "
f"Add an inline [n] citation next to each claim, using ONLY these verified "
f"sources: {sources}. Never invent a source. Draft for review, not publishing."
f"\n{outline}"}])
return res.content[0].text from langchain_google_genai import ChatGoogleGenerativeAI
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash") # or ChatAnthropic(...)
def draft(brief, outline, sources: list[str]) -> str:
return llm.invoke(
f"Write a {brief.words}-word on-brand draft ({brief.style}) for the outline "
f"below. Cite a source inline as [n] beside each claim, using ONLY these "
f"verified sources: {sources}. Do not invent sources. Draft for review only."
f"\n{outline}").content A draft that links only outward leaks the reader away, so the agent adds links to your own pages. This is deterministic: it loads the URLs from your sitemap with their target keywords, and where a keyword appears in the draft it wraps the first mention in a link to that URL. Matching against your own sitemap means it can only ever link to pages that exist, never to a made-up path. Identical in every framework.
import re
def load_sitemap_keywords() -> dict[str, str]:
# keyword -> a real URL on your site, parsed from sitemap.xml
return {"demand forecasting": "/services/demand-forecasting/",
"computer vision": "/services/computer-vision/"}
def add_internal_links(markdown: str, kw_to_url: dict[str, str]) -> str:
for kw, url in kw_to_url.items():
# link only the FIRST mention, and only whole words
markdown = re.sub(rf"\b({re.escape(kw)})\b",
rf"[\1]({url})", markdown, count=1, flags=re.I)
return markdown
# Links only ever point at URLs already in your sitemap, never an invented path. Before a person ever opens the draft, the agent runs it past your rules. This is deterministic: it checks the heading hierarchy, the meta title and description lengths, the word count against the brief, the presence of the target keyword, and a readability score. It does not block, it produces a checklist of pass and fail items so the editor sees what needs attention at a glance. Rules, not a model opinion, so the result is the same every time.
import re, textstat
def seo_checks(brief, markdown: str, meta_title: str, meta_desc: str) -> list[dict]:
words = len(re.findall(r"\w+", markdown))
checks = [
{"name": "word count", "ok": words >= brief.words * 0.8},
{"name": "has H2", "ok": markdown.count("## ") >= 2},
{"name": "keyword present", "ok": brief.keyword.lower() in markdown.lower()},
{"name": "meta title <= 60", "ok": len(meta_title) <= 60},
{"name": "meta desc 50-160", "ok": 50 <= len(meta_desc) <= 160},
{"name": "readable (grade <= 10)", "ok": textstat.flesch_kincaid_grade(markdown) <= 10},
]
return checks # a pass/fail checklist for the editor, never an auto-block This is the guardrail that makes the whole thing trustworthy. The draft, its sources, the link suggestions, and the SEO checklist go into a review queue, where a person edits the copy, verifies the cited facts, keeps or cuts the suggested links, and approves. Only on approval does anything move toward publishing, and even then through your normal CMS step, not the agent. Nothing your readers see was published without a human behind it. Deterministic, and the same in every framework.
def queue_for_review(brief, draft: str, sources: list, checks: list) -> None:
review_queue.add(brief_id=brief.id, draft=draft,
sources=sources, checks=checks) # awaits a human
def on_approval(brief, edited_markdown: str) -> None:
# A person has edited and approved; hand to the CMS as a DRAFT, not live.
cms.create_draft(title=brief.topic, body=edited_markdown)
audit.log("approved_to_cms", brief.id) # only after human approval
# The agent drafts and queues; publishing is a human action in your CMS. The pieces run on each new brief. ADK composes research, outline, and draft under a SequentialAgent and a Runner, the Claude SDK runs them in a tool-use loop, and LangGraph builds an explicit research-outline-draft graph. Whichever you pick, the same guardrails apply: every cited URL is checked to be real and resolving so no source is fabricated, an originality check flags anything too close to a source, internal links only point at your sitemap, the brief id makes every run idempotent, a person edits and approves before publish, and every run 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
content_pipeline = SequentialAgent(name="content_pipeline",
sub_agents=[researcher, outliner, drafter]) # each an LlmAgent with the tools
runner = Runner(agent=content_pipeline, app_name="content",
session_service=InMemorySessionService())
def guard(brief, draft: dict) -> str | None:
if store.seen(brief.id): return "already_drafted" # idempotent
if any(not _ok(u) for u in draft["sources"]): return "bad_source" # must resolve
if originality(draft["text"]) < 0.8: return "too_similar" # plagiarism check
return None
# Run on each new brief via cron / Cloud Scheduler. Publishing always needs a human. from anthropic import Anthropic
client = Anthropic()
TOOLS = [research_tool, outline_tool, draft_tool, link_tool] # JSON schemas
def run_content(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 # no publish tool: drafts only
messages.append({"role": "assistant", "content": resp.content})
for b in resp.content:
if b.type == "tool_use":
out = dispatch(b.name, b.input)
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("research", lambda s: {"research": research(s["topic"])})
g.add_node("outline", lambda s: {"outline": outline(s["brief"], s["research"])})
g.add_node("draft", lambda s: {"draft": draft(s["brief"], s["outline"],
s["research"]["sources"])})
g.add_edge(START, "research")
g.add_edge("research", "outline")
g.add_edge("outline", "draft")
g.add_edge("draft", END)
content_pipeline = g.compile() # invoke per brief; drafts queue for human review
Here is a runnable file. It researches a sample brief with live web search,
verifies that each cited URL resolves, and drafts a titled outline with
inline citations, and it does not publish anything. The brief is inline text;
production reads it from your queue, as in the build steps. Save it as
main.py and run python main.py.
# main.py -- run: python main.py
# A runnable reference: researches a sample brief with live web search, verifies
# each source URL resolves, and drafts an on-brand outline. It does NOT publish
# (publishing is always a human action in your CMS).
# The brief is inline; production reads it from your queue, as in the build steps.
import asyncio
import httpx
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 brief from your queue) ----
SAMPLE_BRIEF = """Topic: demand forecasting for Indian retail SMEs
Audience: small retail owners. Angle: practical, not technical.
Keyword: demand forecasting. Target length: about 1000 words.
Voice: warm, plain English, no jargon, no banned words."""
# --------------------------------------------------------------------
def research(topic: str) -> str:
"""Research the topic with live Google Search and keep only URLs that resolve."""
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Research '{topic}'. List key facts, each with its source URL."],
config=types.GenerateContentConfig(
tools=[types.Tool(google_search=types.GoogleSearch())]))
meta = res.candidates[0].grounding_metadata
urls = [c.web.uri for c in (meta.grounding_chunks or []) if c.web]
good = [u for u in urls if _resolves(u)] # drop any URL that 404s
return res.text + "\n\nVerified sources: " + ", ".join(good)
def _resolves(u: str) -> bool:
"""A cited URL that does not resolve is dropped, never kept."""
try: return httpx.head(u, follow_redirects=True, timeout=8).status_code < 400
except Exception: return False
def outline_and_draft(brief: str, research_notes: str) -> str:
"""Build an outline and an on-brand draft that cites only verified sources."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["Using ONLY the verified sources in the notes, write a title, H2 "
"outline, and a first draft for this brief. Cite each claim inline "
"as [n]. Do not invent sources. This is a draft for human review, "
"not for publishing.\n\nBrief:\n" + brief +
"\n\nResearch notes:\n" + research_notes]).text
agent = LlmAgent(
name="content_agent", model="gemini-2.5-flash",
instruction=("Call research on the brief's topic, then call outline_and_draft "
"with the brief and the research notes. Report the verified sources "
"and the draft. Make clear the draft is queued for human editing and "
"approval, and is not published."),
tools=[research, outline_and_draft])
async def main():
runner = InMemoryRunner(agent=agent, app_name="content")
session = await runner.session_service.create_session(app_name="content", user_id="demo")
msg = types.Content(role="user",
parts=[types.Part(text=f"Draft from 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 the verified sources it kept (any URL that did not resolve is dropped) and a draft with inline citations queued for editing. The exact wording and the sources found are model and search generated, so they vary run to run; publishing is never automatic, and a person edits and approves before anything goes live.
An editor sees one screen: the brief, the on-brand draft with its inline citations, the verified sources and SEO checks, and a single approve action before anything reaches the CMS.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You want your own voice, your sitemap for internal links, verified sources, and the drafts in-house | High | Build cost + API and model usage | Full |
| Content SaaS (Jasper, Copy.ai, Surfer SEO) | Fast drafts and SEO scoring out of the box, you accept their workflow and prompts | Low | Subscription per seat | Medium |
| No-code (n8n or Make with a model node) | A simple brief-to-draft chain, low volume, a quick pilot | Low | Cheap, but brittle as research and linking grow | Low |
The defining safety choice is that the agent never publishes, and never cites a source it has not checked. Research comes from a real search or retrieval tool, every cited URL is verified to resolve so there are no hallucinated sources, and the drafter is told to cite only from that verified set. An originality check flags any passage that sits too close to a source so it can be rewritten before review, and you can add your own plagiarism tool as a final gate.
A person edits and approves every draft before it reaches the CMS, because a link that resolves is not the same as a claim being correct, and judgement about quality and angle stays human. Where you use AI assistance, disclose it in line with your own editorial policy. Any personal data in a brief is handled in line with the DPDP Act 2023, whose core obligations phase in through 2027, and every run is written to an immutable audit log.
These are directional, not promises, and depend on your volume and how much research each piece needs. The point is the shape of the change: faster first drafts, sources that are checked rather than chased, internal links that are not forgotten, steadier voice.
| Metric | Manual | Agent |
|---|---|---|
| Time to a first draft | A writer researches, outlines, and drafts from scratch over hours | A researched, on-brand draft with sources and links is ready to edit, so the writer starts from a strong first version |
| Source quality | Citations are added by hand and sometimes skipped or go stale | Every claim carries a source the agent has checked resolves, so editing is verifying rather than hunting |
| Internal linking | Links to your own pages are forgotten in the rush to publish | Relevant URLs from your sitemap are suggested in place, for the editor to keep or cut |
| On-brand consistency | Voice drifts with whoever is writing that day | Drafts follow one style guide every time, with a person editing before publish |
Time saved depends on content volume and how much research each piece needs. Figures are ranges that vary by team, not guarantees, and quality still rests on the human edit.
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 content agents like this on ADK, the Claude SDK, or LangGraph, wired into your search tool, your sitemap, and your CMS, with a person editing and approving every draft. Or explore the ready-made versions in our agent suite.