Three agents. One pipeline. Zero frameworks.
You have seen the single-agent loop in our previous articles: a while loop that sends history to the model, executes tool calls, and keeps going until it gets a final answer. That loop is the foundation of every agent ever built.
But real-world workflows rarely use a single agent. They chain agents together: one researches, another writes, a third publishes. Each agent has a focused job, a narrow context window, and a clear output format. The output of one agent becomes the input of the next. That is a handoff, and it is the most practical multi-agent pattern you will ever use.
This guide builds a three-agent SEO content pipeline in vanilla TypeScript:
- SEO agent — queries live Google results via DataForSEO, scrapes top-ranking pages via Jina Reader, and produces a structured article brief.
- Writer agent — takes the brief plus the scraped research and writes a full article draft.
- Publisher agent — formats the article into a WordPress-ready REST API payload and posts it as a draft.
Everything is on GitHub: https://github.com/juustesout/typescript-agent-with-handoff-seo-blogger (the handoff example lives in the TShandoff folder).
1. Installatie — More Keys, Same Setup
You need Node.js 18+ and a handful of API keys. Two dev dependencies (TypeScript + @types/node):
git clone https://github.com/juustesout/typescript-agent-with-handoff-seo-blogger
cd javascript-agents-example/TShandoff
npm install
cp .env.example .env
Open .env and add your keys:
OPENAI_API_KEY=sk-proj-your-key-here
OPENAI_MODEL=gpt-4o-mini
OPENAI_BASE_URL=https://api.openai.com/v1
DATAFORSEO_API_KEY=your-dataforseo-key
DATAFORSEO_BASE_URL=https://api.dataforseo.com
JINA_API_KEY=your-jina-key
WP_BASE_URL=https://your-site.com
WP_USERNAME=your-wordpress-username
WP_APPLICATION_PASSWORD=your-wordpress-app-password
Four API integrations, one .env file. The built-in loadEnv() loads them all automatically.
npm run build
npm run demo "AI agents for SEO content workflows"
2. Project Structure
TShandoff/
├── src/
│ ├── miniAgent.ts # Reusable OpenAI-compatible agent loop (same as before)
│ ├── handoff.ts # Three-agent pipeline: SEO → writer → publisher
│ └── demo.ts # Entry point — runs the full pipeline
├── dist/ # Compiled TypeScript output
├── .env.example
├── package.json
└── tsconfig.json
Three source files, one pipeline. The miniAgent.ts is the same MiniAgent class from the previous TypeScript article — the core loop does not change, only the prompts and tools do.
3. The Core Agent Loop — A Quick Refresher
The MiniAgent class is identical to the one in the TypeScript agents example. It is a single while loop:
async run(userPrompt: string): Promise<string> {
this.messages = [
{ role: "system", content: this.systemPrompt },
{ role: "user", content: userPrompt },
];
for (let iteration = 1; iteration <= this.maxToolIters; iteration++) {
const reply = await this._callModel();
if (reply.tool_calls && reply.tool_calls.length) {
await this._handleToolCalls(reply.tool_calls);
continue; // loop back to the model
}
return reply.content || ""; // final answer
}
throw new Error("No final answer after max iterations.");
}
The call to the model, the tool execution, and the tool_call_id protocol are all identical to the earlier TypeScript article. If you have read that, you know this. The difference is in how we use three instances of this class — not in the class itself.
4. The Three Agents
Each agent is a MiniAgent with a different system prompt, a different temperature, and a different set of tools. They do not share state — the output of one is passed as the input to the next as a plain JSON string.
Agent 1 — The SEO Researcher
The SEO agent has one tool: a DataForSEO SERP query that fetches live Google organic results. Its job is to produce a structured ArticleBrief:
export type ArticleBrief = {
topic: string;
audience: string;
angle: string;
keyword: string;
headings: string[];
outline: string[];
tone: string;
seoNotes: string[];
researchContext?: string;
};
The system prompt tells it to use the tool before writing, and to return strict JSON only:
const SEO_PROMPT = `
You are the SEO agent for a blog workflow.
Important: before writing the brief, use the DataForSEO research tool
to inspect live Google organic results. Then use those results to
infer search intent, top competitors, and likely article angle.
Return only valid JSON in this exact structure:
{
"topic": "...",
"audience": "...",
"angle": "...",
"keyword": "...",
"headings": ["..."],
"outline": ["..."],
"tone": "...",
"seoNotes": ["..."]
}
`; The agent is created with a low temperature (0.4) for consistency:
const seoAgent = buildAgent(SEO_PROMPT, {
model: "gpt-4o-mini",
temperature: 0.4,
});
seoAgent.addTool("dataforseo_google_organic_live_advanced",
"Search live Google organic results...",
{ type: "object", properties: { ... }, required: ["keyword"] },
async (args) => fetchDataForSeoGoogleOrganicLiveAdvanced({ ... })
); Agent 2 — The Article Writer
The writer has no tools. It receives the SEO brief plus the scraped research context and produces a complete article draft — title, summary, outline, content, meta description and slug, all as JSON:
const WRITER_PROMPT = `
You are the article writer agent.
You receive a structured SEO brief plus live SERP and scraped
source context. Use those signals to build the article.
Return valid JSON in this exact structure:
{
"title": "...",
"summary": "...",
"outline": ["..."],
"content": "...",
"metaDescription": "...",
"slug": "..."
}
`; The writer runs at a higher temperature (0.8) for creative variation:
const writerAgent = buildAgent(WRITER_PROMPT, {
model: "gpt-4o-mini",
temperature: 0.8,
});
const result = await callModelForJson(writerAgent, `
Use this SEO brief and the live SERP/scrape research to draft the article.
SEO brief: ${JSON.stringify(brief, null, 2)}
SERP + scraped source research: ${brief.researchContext}
`); Agent 3 — The WordPress Publisher
The publisher takes the article draft, the SEO brief and the research context, and formats everything into a WordPress REST API payload. It adds the research-aware framing and ensures the content is ready for the API:
const PUBLISHER_PROMPT = `
You are the WordPress publisher agent.
Format the article draft into a WordPress-ready publish payload
using the article plus the live SERP and scraped source context.
Return valid JSON in this exact structure:
{
"title": "...",
"content": "...",
"status": "draft",
"slug": "...",
"excerpt": "...",
"meta": {
"seo_title": "...",
"meta_description": "..."
}
}
`; The publisher runs at a moderate temperature (0.5) — just enough to frame the research context without rewriting the article.
5. The Handoff — How Data Flows Between Agents
The handoff is the core idea of this project. It is not complex — it is a simple chain of async function calls, each passing its output to the next:
export async function runHandoff(topic: string) {
const brief = await seoResearch(topic); // step 1: SEO
const draft = await writeArticle(brief); // step 2: writer
const publishPayload = await prepareWordPressPayload(draft, brief); // step 3: publisher
const result = await publishToWordPress(publishPayload); // step 4: HTTP
return { brief, draft, publishPayload, result };
}
Each step is a function, not a shared agent context. The data is passed as plain JSON strings, which means:
- No shared memory. Each agent starts with a fresh
messagesarray. It cannot see the previous agent’s conversation. - Clear contract. Each agent returns typed JSON. The next agent receives that JSON and uses it.
- Any agent can be replaced. Swap the SEO agent for a different research provider, or the writer for a different model. The rest of the pipeline does not change.
- Auditable. Every intermediate output can be inspected, logged, or saved.
Here is what happens in detail:
Step 1: SEO Research (seoResearch())
The SEO agent is given a topic and a research context. It calls the dataforseo_google_organic_live_advanced tool, which:
- Sends a POST to the DataForSEO API with the keyword.
- Returns the top 20 organic results (title, URL, domain, snippet).
- The agent (the model, not the code) decides to use this data.
- The
gatherSeoResearch()function then takes the top 10 URLs and scrapes the top 5 through Jina Reader, returning cleaned plain text. - The agent receives the combined SERP + scraped data and produces the
ArticleBriefJSON.
async function seoResearch(topic: string): Promise<ArticleBrief> {
// 1. Build the agent with the DataForSEO tool
const seoAgent = buildAgent(SEO_PROMPT, { temperature: 0.4 });
seoAgent.addTool("dataforseo_google_organic_live_advanced", ..., async (args) => {
return fetchDataForSeoGoogleOrganicLiveAdvanced({ keyword: String(args.keyword) });
});
// 2. Gather live SERP + scrape top sources
const researchContext = await gatherSeoResearch(topic);
// 3. Let the agent produce the brief
const result = await callModelForJson(seoAgent,
`Create a blog SEO brief for: ${topic}. Research: ${researchContext}`
);
return { topic, audience, angle, keyword, headings, outline, tone, seoNotes, researchContext };
} Step 2: Article Writing (writeArticle())
The writer receives the brief including the research context. It has no tools — it only uses the model’s ability to turn structured data into prose. The full brief and scraped sources are included in the prompt:
async function writeArticle(brief: ArticleBrief) {
const writerAgent = buildAgent(WRITER_PROMPT, { temperature: 0.8 });
const result = await callModelForJson(writerAgent, `
Use this SEO brief and the live SERP/scrape research to draft the article.
SEO brief: ${JSON.stringify(brief, null, 2)}
SERP + scraped source research: ${brief.researchContext ?? "No research context."}
`);
return { title, summary, outline, content, metaDescription, slug };
} Step 3: WordPress Payload (prepareWordPressPayload())
The publisher receives the draft, the brief and the research context. It decides how to frame the article for the blog audience, adds a research-aware intro, and produces the exact payload the WordPress REST API expects:
async function prepareWordPressPayload(draft, brief) {
const publisherAgent = buildAgent(PUBLISHER_PROMPT, { temperature: 0.5 });
const payloadInput = { draft, brief, researchContext: JSON.parse(brief.researchContext) };
const result = await callModelForJson(publisherAgent,
`Prepare the WordPress payload: ${JSON.stringify(payloadInput, null, 2)}`
);
return { title, content, status: "draft", slug, excerpt, meta };
} Step 4: WordPress HTTP (publishToWordPress())
This is not an agent — it is a plain function that sends the payload to the WordPress REST API. No model involved, just fetch() with Basic auth:
export async function publishToWordPress(payload) {
const credentials = Buffer.from(`${username}:${password}`).toString("base64");
const response = await fetch(
`${baseUrl}/wp-json/wp/v2/posts`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${credentials}`,
},
body: JSON.stringify({
title: payload.title,
content: payload.content,
status: payload.status,
slug: payload.slug,
excerpt: payload.excerpt,
}),
}
);
const json = await response.json();
return { success: true, postId: json.id, slug: json.slug, url: json.link };
} 6. The Research Tools
Two tools power the research phase. Both are plain async functions — no SDK, no framework.
DataForSEO SERP query
The handoff uses a different approach from the MiniAgent tool pattern: the fetchDataForSeoGoogleOrganicLiveAdvanced() function is called both by the agent (as a tool) and outside the agent (by gatherSeoResearch() to scrape the top URLs). It sends a keyword to the DataForSEO API and returns the top 20 organic results:
export async function fetchDataForSeoGoogleOrganicLiveAdvanced({
keyword, locationCode = 2840, languageCode = "en", depth = 100,
}) {
const authHeader = getDataForSeoAuthHeader();
const response = await fetch(
`${baseUrl}/v3/serp/google/organic/live/advanced`,
{
method: "POST",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
body: JSON.stringify([{ keyword, location_code: locationCode,
language_code: languageCode, depth }]),
}
);
const json = JSON.parse(await response.text());
const items = json.tasks?.[0]?.result?.[0]?.items ?? [];
return JSON.stringify({
keyword, total_results: items.length,
top_results: items.slice(0, 20).map((item, i) => ({
rank: i + 1, title: item.title, url: item.url,
domain: item.domain, snippet: item.description,
})),
});
} Jina Reader scraping
Jina Reader (r.jina.ai) turns any URL into clean, readable text. It is used to scrape the top 5 ranking pages so the writer can reference real content from the SERP:
export async function scrapeJinaReader(url: string) {
const jinaUrl = `https://r.jina.ai/http://${url.replace(/^https?:\/\//i, "")}`;
const headers: Record<string, string> = { accept: "text/plain" };
const apiKey = process.env.JINA_API_KEY;
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const response = await fetch(jinaUrl, { headers });
const text = await response.text();
return { url, text: text.trim() };
} The scraped content is truncated to 1800 characters per source to keep the context window manageable. The entire research package (SERP + 5 scraped pages) typically fits in ~10K tokens, well within the range of gpt-4o-mini.
7. Running the Demo
npm run build
node dist/demo.js "AI agents for SEO content workflows"
What happens:
Starting a 3-agent handoff workflow...
--- Step 1: SEO agent calls DataForSEO + scrapes top 5 URLs ---
--- Step 2: Writer agent produces article draft ---
--- Step 3: Publisher agent builds WordPress payload ---
--- Step 4: Publishing to WordPress REST API ---
SEO brief:
{
"topic": "AI agents for SEO content workflows",
"audience": "SEO professionals and content marketers",
"angle": "How AI agents can automate keyword research, content briefs, and SERP analysis",
"headings": ["Introduction", "What are AI agents for SEO?", "How to build an SEO agent pipeline", ...],
"seoNotes": ["Focus on automation", "Include real workflow examples"]
}
Draft article:
{
"title": "AI Agents for SEO Content Workflows: A Practical Guide to Automation",
"summary": "Learn how to build a three-agent SEO pipeline...",
"slug": "ai-agents-seo-content-workflows"
}
WordPress payload:
{
"title": "AI Agents for SEO Content Workflows: A Practical Guide to Automation",
"status": "draft",
"slug": "ai-agents-seo-content-workflows"
}
Publish result:
{
"success": true,
"postId": 1234,
"slug": "ai-agents-seo-content-workflows",
"status": "draft",
"url": "https://your-site.com/?p=1234"
}
Note: the WordPress step requires valid WP_BASE_URL, WP_USERNAME and WP_APPLICATION_PASSWORD in .env. Without them, the publish step returns a clean error message instead of crashing.
8. The Handoff Pattern — Why It Matters
The handoff pattern is the most underrated idea in agent development. It solves three problems that single-agent systems struggle with:
Problem 1: Context window limits
A single agent that researches, writes and publishes would need to hold the entire SERP, the scraped sources, the brief, the draft and the API payload in one conversation. That is 15K+ tokens of context, most of it irrelevant at any given step. A handoff pipeline keeps each agent’s context focused on its job.
Problem 2: Prompt interference
When one system prompt covers research, writing and publishing, the instructions compete. The writing rules leak into the research phase, the publishing constraints distract the writer. Separate agents with separate prompts separate the concerns cleanly.
Problem 3: Debugging and iteration
When a pipeline fails, you want to know which step failed. A handoff saves every intermediate output. You can inspect the brief, the draft, the payload — and rerun only the failed step with a fixed prompt, without redoing the entire pipeline.
The handoff is also the most natural pattern for teams: one person researches, another writes, a third publishes. Agents are no different.
9. Putting It All Together
Here is the complete checklist to build your own handoff pipeline:
- Define your types —
ArticleBrief,PublishResult, and the intermediate shapes. Each type is the contract between two agents. - Write the prompts — one system prompt per agent. Each prompt tells the agent exactly what JSON to return. Strict JSON output is the most important rule.
- Build the research tools — DataForSEO for SERP data, Jina Reader for content scraping. Keep them as plain async functions, not agent tools.
- Chain the agents —
seoResearch() → writeArticle() → prepareWordPressPayload() → publishToWordPress(). Each function calls the next with the previous output. - Validate JSON at every step — the
assertJsonObject()helper catches malformed model output before it reaches the next agent. - Audit the outputs — log every intermediate result. When a pipeline fails, you will know exactly which step produced bad data.
That is the entire pattern. The handoff is not a framework feature — it is a chain of async function calls, each with a typed input and a typed output. No shared state, no event bus, no orchestration engine. Just functions, prompts, and a clear boundary between them.
Going Further
- Add a review agent — insert a quality-check agent between the writer and the publisher. Give it a rubric and ask it to score the draft before publishing.
- Swap the research provider — replace DataForSEO with Google’s Custom Search API, or replace Jina with your own scraper. The interface never changes: keyword in, structured results out.
- Parallel research — run the SEO agent for multiple keywords in parallel, then merge the briefs before writing.
Promise.all()is all you need. - Schedule the pipeline — wrap the handoff in a cron job or a webhook endpoint. The pipeline is a single async function.
- Different models per agent — use
gpt-4ofor the research and writing,gpt-4o-minifor the publisher. TheMiniAgentconstructor accepts a per-agent model. - Add images — the publisher agent could include a DALL-E prompt in the payload, or you could add a fourth agent that generates the featured image.
The full source is at https://github.com/juustesout/typescript-agent-with-handoff-seo-blogger in the TShandoff folder. Clone it, open the files, and trace the handoff from the first line of seoResearch() to the last line of publishToWordPress() — it is four functions, and you already understand every one of them.