cd /news/developer-tools/full-md Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-114457] src=gist.github.com β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

full.md

A hands-on coding lab for the React Ladies community workshop demonstrates building a multi-agent content creation app using LangChain's createAgent, with a supervisor agent orchestrating five specialist agents to research, write, illustrate, fact-check, and promote an article. The lab, structured in four parts, uses Node.js, TypeScript, and free-tier API keys from OpenAI and Tavily, and includes a bonus section for a debugging UI and custom React chat interface.

read36 min views1 publishedAug 28, 2026

A hands-on coding exercise for the "React Ladies" community workshop.

In this lab you'll build a multi-agent content creation app from an empty folder, live, using LangChain's createAgent

β€” no LangGraph graph API needed. Give it a topic, and a supervisor agent orchestrates five specialist agents to research, write, illustrate, fact-check, and promote a full article. Every artifact lands as a markdown file (plus one image) β€” no database required.

Agent Powered by Output
Research agent Tavily web search output/research.md
Technical writer agent OpenAI chat model output/article.md
Image agent OpenAI Images API (gpt-image-1 )
output/cover.png + output/cover.md
Fact-checker agent Tavily web search output/fact-check.md
Social media agent Skills pattern (platform prompts loaded on demand) output/linkedin-post.md

This lab is broken into 4 parts, each ending with a working, runnable checkpoint. There's no spoken script β€” just follow the steps, type the code, and run it as you go. A Bonus section at the end adds a debugging UI (LangGraph Studio) and a custom React chat interface, for anyone who finishes early.

  • Node.js β‰₯ 20 installed

  • A code editor (VS Code / WebStorm)

  • Basic TypeScript knowledge

  • Two free-tier API keys β€” get these before we start (instructions below):

  • OpenAI

  • Tavily

  • Go to platform.openai.comand sign up or log in. - OpenAI's API requires a payment method on file (even for small usage) β€” go to Settings β†’ Billing and add a card. We'll be making a handful of cheap chat calls and, in Part 4, one image generation call. - Go to Settings β†’ API keys(orplatform.openai.com/api-keys). - Click Create new secret key, give it a name (e.g.react-ladies-lab

), and clickCreate. Copy the key immediatelyβ€” OpenAI only shows it once. It starts withsk-

.

  • Go to app.tavily.comand sign up (free tier available, no card required). - Once logged in, your API key is shown right on the dashboard homepage. It starts withtvly-

. - Copy it β€” you can always come back to the dashboard to view it again.

Keep both keys handy β€” we'll paste them into a .env

file in Part 1, Step 1.3.

Goal: a working TypeScript project with an OpenAI chat model wired up, and a first agent you can talk to from the terminal.

By the end of Part 1:

content-studio/
β”œβ”€β”€ .env
β”œβ”€β”€ .gitignore
β”œβ”€β”€ package.json
β”œβ”€β”€ tsconfig.json
└── src/
    β”œβ”€β”€ models.ts
    └── index.ts
mkdir content-studio && cd content-studio
npm init -y
npm install langchain @langchain/openai @langchain/core @langchain/tavily zod dotenv
npm install -D typescript tsx @types/node

langchain

is the main package β€” that's where createAgent

lives. @langchain/openai

is the OpenAI provider. @langchain/tavily

gives us web search (Part 2). zod

describes tool inputs (Part 2). dotenv

loads our API keys from .env

. tsx

runs TypeScript files directly β€” no compile step, perfect for fast iteration.

Open package.json

and add:

{
  "type": "module",
  "scripts": {
    "start": "tsx src/index.ts"
  }
}

"type": "module"

tells Node we're using ES modules (import

/export

), which is what lets us use top-level await

later.

Create tsconfig.json

:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"],
    "outDir": "dist"
  },
  "include": ["src"]
}

NodeNext

module resolution matches our ES-module setup, strict

keeps type-checking honest, and target: ES2022

gives us top-level await.

Create .gitignore

first, before adding any real keys:

node_modules/
dist/
.env
output/

Create .env

and paste in the two keys you got above:

OPENAI_API_KEY=sk-...
TAVILY_API_KEY=tvly-...

The variable names matter β€” LangChain's OpenAI and Tavily integrations look for exactly OPENAI_API_KEY

and TAVILY_API_KEY

in the environment. If the names match, everything wires up automatically; we never pass keys around in code. output/

is also in .gitignore

β€” that's where our agents will write their markdown files.

initChatModel

is LangChain's universal model factory: pass a model name, get back a standardized chat model β€” the provider is inferred from the name. Swapping providers later means changing one string.

Create src/models.ts

:

import "dotenv/config";
import { initChatModel } from "langchain";

/**
 * Model configuration
 * -------------------
 * initChatModel is LangChain's universal model factory: pass a model name,
 * get back a standardized chat model. Provider is inferred from the name.
 *
 * Key options:
 *   temperature β€” creativity dial. 0 = deterministic, 1 = creative.
 *   maxTokens   β€” hard cap on response length.
 *   timeout     β€” ms before a hanging request is aborted.
 */

// Balanced model for reasoning-heavy work (research, orchestration)
export const model = await initChatModel("gpt-4o", {
  temperature: 0.3,
  timeout: 60_000,
});

// Creative model for writing tasks (articles, social posts)
export const creativeModel = await initChatModel("gpt-4o", {
  temperature: 0.8,
  maxTokens: 4000,
  timeout: 60_000,
});

import "dotenv/config"

at the top loads .env

before anything else runs. We export two models from the same underlying gpt-4o

, with different temperatures: 0.3 for research/orchestration (focused, factual, repeatable), 0.8 for writing (varied word choice, engaging prose). Same model, different personality β€” a design decision about our agents. The top-level await

works because of our ES-modules setup.

An agent, in LangChain terms, is a model that can act: it runs in a loop, can call tools, and decides for itself when it's done. createAgent

packages that into one call with three ingredients: a model, a list of tools, and a system prompt. Today's agent gets no tools yet (that's Part 2) β€” but even without tools, the system prompt already shapes everything.

Create src/index.ts

:

import { createAgent } from "langchain";
import { HumanMessage } from "@langchain/core/messages";
import { model } from "./models.js";

/**
 * Our first agent: model + system prompt. No tools yet.
 * The system prompt defines WHO the agent is and HOW it should behave.
 */
const agent = createAgent({
  model,
  tools: [],
  systemPrompt:
    "You are a content strategist for a technology blog. " +
    "When given a topic, respond with a one-paragraph angle for an article: " +
    "who the audience is, what the hook is, and why now. Be concrete.",
});

// Read the topic from the command line
const topic = process.argv[2] ?? "The rise of edge computing";

const result = await agent.invoke({
  messages: [new HumanMessage(topic)],
});

// The result is a list of messages; the agent's answer is the last one
console.log(result.messages.at(-1)?.content);

Notice the system prompt isn't "you are a helpful assistant" β€” it defines a role (content strategist), a task (propose an angle), and an output contract (one paragraph: audience, hook, why now). Specific prompts produce specific behavior β€” more on this in Part 2.

invoke

takes a list of messages β€” here a single HumanMessage

with the topic from the command line. What comes back is the full conversation: our message plus everything the agent produced. The agent's final answer is always the last message: result.messages.at(-1)

β€” an expression you'll type constantly in this lab.

Run it:

npm start -- "WebAssembly on the server"

Try running it again with the same topic to see the response vary. Optionally swap model

for creativeModel

in index.ts

and re-run to see the tone shift.

This will matter a lot once we add tools. Temporarily change the last line of src/index.ts

:

for (const message of result.messages) {
  console.log(`[${message.getType()}]`, String(message.content).slice(0, 100));
}

Run again β€” you'll see [human]

followed by [ai]

. Just two messages for now. Once we add tools in Part 2, this list grows: you'll see the AI deciding to call a tool, the tool's result coming back, and the AI reasoning over it. The message list is the agent's entire working memory, and reading it is your number one debugging skill.

Revert to printing just the last message before moving on:

console.log(result.messages.at(-1)?.content);

βœ… Checkpoint: you have a working TypeScript project, two configured models, and a first agent that responds on the command line.

Goal: learn how tools work (tool()

  • Zod), connect Tavily web search, build a custom markdown file-writing tool, and apply prompt engineering to build a research agent that produces output/research.md

.

By the end of Part 2:

content-studio/
└── src/
    β”œβ”€β”€ models.ts
    β”œβ”€β”€ tools/
    β”‚   └── save-markdown.ts
    β”œβ”€β”€ agents/
    β”‚   └── research-agent.ts
    └── index.ts
output/
└── research.md        ← generated!

A tool is a function the agent can decide to call. The agent reads the tool's name, description, and input shape β€” and when it thinks the tool would help, it calls it, gets a result back, and keeps reasoning. That loop β€” think, act, observe, repeat β€” is the heart of what makes an agent an agent.

Tavily is a search API built for LLMs β€” it returns clean, summarized results instead of raw HTML. We already installed @langchain/tavily

and set TAVILY_API_KEY

in Part 1, so wiring it up is two lines.

Create src/agents/research-agent.ts

and start with:

import { createAgent } from "langchain";
import { TavilySearch } from "@langchain/tavily";
import { model } from "../models.js";

// Pre-built tool: web search designed for LLMs.
// maxResults keeps responses focused (and cheap).
const searchWeb = new TavilySearch({ maxResults: 5, name: "search_web" });

maxResults: 5

caps how many results the agent reads (every result costs tokens). name: "search_web"

matters too β€” the tool's name is part of the prompt the model sees. A clear, verb-first name genuinely improves how reliably the agent uses it. Naming things well is prompt engineering.

Our app's rule is: every agent produces markdown files. No pre-built tool does exactly that, so we write our own. A custom tool is the tool()

function with two arguments: an async implementation function, and a config object with a name, description, and a schema. The schema is written with Zod β€” and here's the key insight: it isn't just validation. LangChain converts it into a description the model reads. Every .describe()

is a sentence of documentation for the AI. You're writing an API for a language model.

Create src/tools/save-markdown.ts

:

import { tool } from "langchain";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import z from "zod";

const OUTPUT_DIR = "output";

/**
 * Custom tool: saves markdown content to the output/ folder.
 * The Zod schema is converted to a spec the model reads β€” every
 * .describe() is documentation for the AI, not just validation.
 */
export const saveMarkdown = tool(
  async ({ filename, content }) => {
    await mkdir(OUTPUT_DIR, { recursive: true });
    const filePath = path.join(OUTPUT_DIR, filename);
    await writeFile(filePath, content, "utf-8");
    return `Saved ${content.length} characters to ${filePath}`;
  },
  {
    name: "save_markdown",
    description:
      "Save markdown content to a file in the output folder. " +
      "Use this to persist your final work product.",
    schema: z.object({
      filename: z
        .string()
        .describe("File name including the .md extension, e.g. 'research.md'"),
      content: z.string().describe("The full markdown content to save"),
    }),
  }
);

The implementation is plain Node: ensure output/

exists, write the file. Notice the return value β€” a confirmation string. Tools should always return a meaningful string, because it goes back into the agent's message list; it's how the agent knows the action succeeded. Return nothing, and the agent may retry, or worse, claim it saved a file it didn't. The config gives it a verb-first name, a description of when to use it, and a Zod schema with a .describe()

on every field, including an example filename.

A good agent prompt has four parts: Role (who is the agent β€” give it a profession and a standard of quality, not "you are helpful"), Task (exactly what to do, numbered if order matters), Output contract (the exact structure of what it produces), and Constraints (guardrails β€” what it must always or never do).

Continue in src/agents/research-agent.ts

:

import { saveMarkdown } from "../tools/save-markdown.js";

/**
 * Prompt anatomy: ROLE β†’ TASK β†’ OUTPUT CONTRACT β†’ CONSTRAINTS.
 * Template literals keep long prompts readable.
 */
const RESEARCH_PROMPT = `
You are a senior research analyst for a technology publication.
Your research is thorough, current, and always source-backed.

## Your task
Given a topic:
1. Run 2-3 web searches covering different angles of the topic
   (state of the art, real-world adoption, criticism/challenges).
2. Synthesize the findings into a research brief.
3. Save the brief using the save_markdown tool as "research.md".

## Output contract β€” research.md must contain exactly these sections:
## Key Findings        (5-8 bullet points, each with a concrete fact)
## Notable Sources     (list of URLs found during search)
## Suggested Angle     (one paragraph: the most compelling story here)

## Constraints
- Every key finding must come from search results, not prior knowledge.
- Include specific numbers, dates, and names wherever possible.
- After saving the file, reply with a 2-3 sentence summary of what you found.
`.trim();

export const researchAgent = createAgent({
  model,
  tools: [searchWeb, saveMarkdown],
  systemPrompt: RESEARCH_PROMPT,
});

The role β€” "senior research analyst… always source-backed" β€” biases every decision toward citing sources. The task is numbered and explicitly asks for 2-3 searches from different angles (without this, agents typically search once and stop). The output contract is the part people skip and the most important one here, because a downstream agent will read this file in Part 3 β€” if the structure is stable, the pipeline is stable. The constraints close the loopholes: "from search results, not prior knowledge" fights hallucination, and "reply with a summary after saving" gives a clean confirmation message that the supervisor will want later.

Replace src/index.ts

:

import { HumanMessage } from "@langchain/core/messages";
import { researchAgent } from "./agents/research-agent.js";

const topic = process.argv[2] ?? "The rise of edge computing";

console.log(`πŸ”Ž Researching: ${topic}\n`);

const result = await researchAgent.invoke({
  messages: [new HumanMessage(`Research this topic: ${topic}`)],
});

console.log(result.messages.at(-1)?.content);
npm start -- "WebAssembly on the server"

While it runs: the agent decides on its own to call search_web

(likely more than once, since the prompt asks for multiple angles). Each result goes back into its message list. Then it composes the brief, calls save_markdown

, gets the confirmation string back, and writes its final summary reply.

Open output/research.md

and check the structure matches the output contract's headings exactly.

Optionally, add this debugging view to watch the agent think β†’ act β†’ observe:

// Debugging view: watch the agent think β†’ act β†’ observe
for (const message of result.messages) {
  const type = message.getType();
  const preview = String(message.content).slice(0, 120);
  console.log(`[${type}] ${preview}\n`);
}

You'll see: human, then AI with a tool call, then a tool result, another tool call, another result… and the final AI answer. Whenever an agent misbehaves, print this list first β€” the answer is almost always in there.

  • Temporarily replace RESEARCH_PROMPT

with"You are a helpful research assistant. Save your findings as markdown."

, run, and compareresearch.md

: fewer searches, vaguer findings, unstable structure. - Restore the full prompt. Try tweaking one constraint (e.g., "exactly 5 key findings") and see how directly the output follows.

Same model, same tools β€” dramatically different quality. When an agent underperforms, your first suspect is never the model. It's the prompt.

βœ… Checkpoint: your research agent searches the web and produces a well-structured research.md

.

Goal: understand why and when to go multi-agent, learn the sub-agents-as-tools (supervisor) pattern, build the technical writer and fact-checker agents, and orchestrate a 3-agent pipeline: research β†’ write β†’ fact-check.

By the end of Part 3:

content-studio/
└── src/
    β”œβ”€β”€ models.ts
    β”œβ”€β”€ tools/
    β”‚   └── save-markdown.ts
    β”œβ”€β”€ agents/
    β”‚   β”œβ”€β”€ research-agent.ts
    β”‚   β”œβ”€β”€ writer-agent.ts
    β”‚   β”œβ”€β”€ fact-checker-agent.ts
    β”‚   └── supervisor.ts
    └── index.ts
output/
β”œβ”€β”€ research.md
β”œβ”€β”€ article.md         ← new!
└── fact-check.md      ← new!

Why not just keep adding tools and instructions to one agent until it does everything? Three reasons:

Focusβ€” a prompt that says "you are a researcher and also a writer and also a fact-checker" produces an agent that's mediocre at all three. Narrow prompts produce sharp behavior.Contextβ€” every search result, draft, and verification pass piles into one message list. Long contexts degrade quality and cost money. Specialists start fresh.Independenceβ€” a fact-checker that shares memory with the writer is grading its own homework. A fact-checker that only receives the finished article, with no knowledge of how it was written, is genuinely skeptical.

The pattern: sub-agents as tools (the supervisor pattern). One sentence: an agent wrapped in a tool() function is just a tool β€” so another agent can call it. The supervisor holds the conversation with the user and decides what happens; specialists do the work, each in its own clean context, and report back.

Sub-agents are stateless β€” every call starts fresh; only the supervisor keeps history.

Sub-agents pass work to each other through the markdown files β€” the researcher writes research.md

, the writer will read it. Add a read-back tool next to the save tool.

Add to src/tools/save-markdown.ts

:

import { readFile } from "node:fs/promises";

export const readMarkdown = tool(
  async ({ filename }) => {
    const filePath = path.join(OUTPUT_DIR, filename);
    return await readFile(filePath, "utf-8");
  },
  {
    name: "read_markdown",
    description:
      "Read a markdown file from the output folder. " +
      "Use this to load work produced by previous steps.",
    schema: z.object({
      filename: z.string().describe("File name to read, e.g. 'research.md'"),
    }),
  }
);

The file system is our shared state β€” no database needed. Each agent's output contract (Part 2) guarantees the next agent can rely on the file's structure.

The writer gets no web search β€” it works only from the research brief, a deliberate constraint that keeps it from wandering off and inventing facts. Its tools are exactly read_markdown

and save_markdown

. This is also where creativeModel

from Part 1 earns its place: temperature 0.8 for prose, vs. 0.3 for the researcher's facts.

Create src/agents/writer-agent.ts

:

import { createAgent } from "langchain";
import { creativeModel } from "../models.js";
import { saveMarkdown, readMarkdown } from "../tools/save-markdown.js";

const WRITER_PROMPT = `
You are a senior technical writer for a respected technology publication.
Your writing is clear, engaging, and precise β€” never marketing fluff.

## Your task
1. Read the research brief using read_markdown ("research.md").
2. Write a complete technical article based ONLY on that research.
3. Save it using save_markdown as "article.md".

## Output contract β€” article.md structure:
*<One-sentence subtitle>*
## Introduction        (hook the reader, why this matters now)
## <2-4 body sections with descriptive headings>
## Conclusion          (takeaways + a forward-looking closing thought)

## Constraints
- 800-1200 words.
- Every factual claim must come from the research brief.
- Explain technical terms on first use; assume a smart but busy reader.
- After saving, reply with the article title and a one-line description.
`.trim();

export const writerAgent = createAgent({
  model: creativeModel,
  tools: [readMarkdown, saveMarkdown],
  systemPrompt: WRITER_PROMPT,
});

Same prompt anatomy as Part 2 β€” role, task, contract, constraints β€” but the task steps choreograph tool use in plain English: read the file first, then write, then save. The constraint "every claim must come from the research brief" turns a creative model into a grounded creative model.

This specialist gets web search back β€” verification needs independent sources β€” plus the file tools. Its prompt has a personality trait the others don't: professional skepticism.

Create src/agents/fact-checker-agent.ts

:

import { createAgent } from "langchain";
import { TavilySearch } from "@langchain/tavily";
import { model } from "../models.js";
import { saveMarkdown, readMarkdown } from "../tools/save-markdown.js";

const factCheckWeb = new TavilySearch({ maxResults: 3, name: "fact_check_web" });

const FACT_CHECKER_PROMPT = `
You are a meticulous fact-checker. You trust nothing without a source.

## Your task
1. Read the article using read_markdown ("article.md").
2. Extract the 4-6 most important factual claims.
3. Verify each claim with fact_check_web searches.
4. Save your report using save_markdown as "fact-check.md".

## Output contract β€” fact-check.md structure:
## Verdict Summary     (one line: how many verified / unverified / false)
## Claims
For each claim:
### Claim: "<the claim>"
- **Verdict:** βœ… Verified | ⚠️ Unverified | ❌ False
- **Evidence:** <what you found, with source URL>

## Constraints
- Check claims independently β€” do not assume the article is correct.
- If a claim is False, quote the correct information with its source.
- After saving, reply with the verdict summary line only.
`.trim();

export const factCheckerAgent = createAgent({
  model,
  tools: [readMarkdown, factCheckWeb, saveMarkdown],
  systemPrompt: FACT_CHECKER_PROMPT,
});

This agent never talks to the writer. It reads the finished article cold, searches the web independently, and files a report. That independence is architectural β€” it comes from the pattern, not from asking nicely in a prompt.

We have three specialist agents. To let a supervisor use them, wrap each one in a tool()

β€” the same tool()

used for saving files. Inside the tool function, invoke the agent with a fresh message and return only its last message β€” the final answer. The supervisor never sees the sub-agent's searches, drafts, or internal back-and-forth. Clean interfaces between agents, just like clean interfaces between functions.

Create src/agents/supervisor.ts

:

import { createAgent, tool } from "langchain";
import { HumanMessage } from "@langchain/core/messages";
import z from "zod";
import { model } from "../models.js";
import { researchAgent } from "./research-agent.js";
import { writerAgent } from "./writer-agent.js";
import { factCheckerAgent } from "./fact-checker-agent.js";

/**
 * SUB-AGENTS AS TOOLS (supervisor pattern)
 * ----------------------------------------
 * Each wrapper: (1) invokes the sub-agent with a fresh HumanMessage,
 * (2) returns only the last message β€” the sub-agent's final answer.
 * Sub-agents are STATELESS: every call starts with a clean context.
 * The supervisor is the only agent holding the full conversation.
 */

const runResearcher = tool(
  async ({ topic }) => {
    console.log("  πŸ”Ž Research agent working...");
    const result = await researchAgent.invoke({
      messages: [new HumanMessage(`Research this topic: ${topic}`)],
    });
    return result.messages.at(-1)?.content as string;
  },
  {
    name: "run_researcher",
    description:
      "Research a topic on the web and save a research brief to research.md. " +
      "Returns a summary of the findings.",
    schema: z.object({ topic: z.string().describe("The topic to research") }),
  }
);

const runWriter = tool(
  async ({ topic }) => {
    console.log("  ✍️  Writer agent working...");
    const result = await writerAgent.invoke({
      messages: [
        new HumanMessage(
          `Write the article about "${topic}" based on research.md.`
        ),
      ],
    });
    return result.messages.at(-1)?.content as string;
  },
  {
    name: "run_writer",
    description:
      "Write a technical article from research.md and save it to article.md. " +
      "Requires run_researcher to have completed first.",
    schema: z.object({ topic: z.string().describe("The article topic") }),
  }
);

const runFactChecker = tool(
  async () => {
    console.log("  βœ… Fact-checker agent working...");
    const result = await factCheckerAgent.invoke({
      messages: [new HumanMessage("Fact-check the article in article.md.")],
    });
    return result.messages.at(-1)?.content as string;
  },
  {
    name: "run_fact_checker",
    description:
      "Verify the claims in article.md and save a report to fact-check.md. " +
      "Requires run_writer to have completed first. Returns the verdict summary.",
    schema: z.object({}),
  }
);

Three details worth noticing: the console logs give visible progress once agents start calling agents (a poor man's observability). The tool descriptions encode pipeline order right into the text β€” "Requires run_researcher to have completed first" β€” so the supervisor's model understands the dependencies. And each sub-agent was prompted (Part 2) to end with a short summary after saving its file β€” that summary is exactly what these wrappers return to the supervisor.

The supervisor is just another createAgent

β€” same abstraction, one level up. Its tools happen to be entire agents, and its prompt is a pipeline prompt that describes the team and the order of operations.

Continue in src/agents/supervisor.ts

:

const SUPERVISOR_PROMPT = `
You are the editor-in-chief of a content studio, orchestrating a team
of specialist agents to produce a complete content package.

Your team (available as tools):
- run_researcher   β€” researches a topic, saves research.md
- run_writer       β€” writes the article from research, saves article.md
- run_fact_checker β€” verifies the article's claims, saves fact-check.md

For every topic, run this pipeline IN ORDER:
1. run_researcher with the topic
2. run_writer with the topic
3. run_fact_checker
4. Report to the user: the article title, the fact-check verdict summary,
   and the list of files produced.

Never skip a step. Never write content yourself β€” delegate everything.
`.trim();

export const supervisor = createAgent({
  model,
  tools: [runResearcher, runWriter, runFactChecker],
  systemPrompt: SUPERVISOR_PROMPT,
});

Update src/index.ts

:

import { HumanMessage } from "@langchain/core/messages";
import { supervisor } from "./agents/supervisor.js";

const topic = process.argv[2] ?? "The rise of edge computing";

console.log(`πŸ“° Content studio starting on: ${topic}\n`);

const result = await supervisor.invoke({
  messages: [new HumanMessage(`Create a content package about: ${topic}`)],
});

console.log(`\n${result.messages.at(-1)?.content}`);

One line in the prompt deserves attention: "Never write content yourself β€” delegate everything." Without it, a capable supervisor will sometimes get impatient and just write the article itself, skipping the pipeline. Pipeline prompts should always say both what to do and what not to do.

npm start -- "Post-quantum cryptography migration"

Watch the progress logs appear in order. When it finishes, open output/

and look at research.md

, article.md

, and fact-check.md

β€” notice how each file feeds the next, each in the exact structure of its output contract, each produced by an agent that started with a completely empty context.

βœ… Checkpoint: a 3-agent pipeline β€” researcher β†’ writer β†’ fact-checker β€” running end to end, coordinated by a supervisor agent.

Goal: build the image agent (custom tool over OpenAI's Images API), build the social media agent using the skills pattern, wire all five agents into the supervisor, and run the complete content studio end to end.

By the end of Part 4 β€” the final app:

content-studio/
└── src/
    β”œβ”€β”€ models.ts
    β”œβ”€β”€ tools/
    β”‚   β”œβ”€β”€ save-markdown.ts
    β”‚   └── generate-image.ts
    β”œβ”€β”€ agents/
    β”‚   β”œβ”€β”€ research-agent.ts
    β”‚   β”œβ”€β”€ writer-agent.ts
    β”‚   β”œβ”€β”€ fact-checker-agent.ts
    β”‚   β”œβ”€β”€ image-agent.ts
    β”‚   β”œβ”€β”€ social-agent.ts
    β”‚   └── supervisor.ts
    └── index.ts
output/
β”œβ”€β”€ research.md
β”œβ”€β”€ article.md
β”œβ”€β”€ fact-check.md
β”œβ”€β”€ cover.png          ← new!
β”œβ”€β”€ cover.md            ← new!
└── linkedin-post.md   ← new!

Chat models talk; image models paint. OpenAI's gpt-image-1

isn't a chat model, so initChatModel

doesn't apply β€” instead we call the Images API directly with the openai

SDK and wrap that call in a custom tool. This pattern generalizes: any API β€” image generation, text-to-speech, an internal company service β€” becomes agent-usable the moment you wrap it in tool()

with a good schema.

Install the SDK:

npm install openai

Create src/tools/generate-image.ts

:

import { tool } from "langchain";
import OpenAI from "openai";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import z from "zod";

const openai = new OpenAI(); // reads OPENAI_API_KEY from env
const OUTPUT_DIR = "output";

/**
 * Custom tool wrapping the OpenAI Images API.
 * Any external API becomes agent-usable with tool() + a clear schema.
 */
export const generateImage = tool(
  async ({ prompt, filename }) => {
    const response = await openai.images.generate({
      model: "gpt-image-1",
      prompt,
      size: "1536x1024", // landscape β€” good for article covers
    });

    const b64 = response.data?.[0]?.b64_json;
    if (!b64) return "Image generation failed: no image data returned.";

    await mkdir(OUTPUT_DIR, { recursive: true });
    const filePath = path.join(OUTPUT_DIR, filename);
    await writeFile(filePath, Buffer.from(b64, "base64"));
    return `Image saved to ${filePath}`;
  },
  {
    name: "generate_image",
    description:
      "Generate an image from a text prompt using an AI image model " +
      "and save it as a PNG file in the output folder.",
    schema: z.object({
      prompt: z
        .string()
        .describe(
          "Detailed visual description: subject, style, mood, colors, composition"
        ),
      filename: z.string().describe("File name with .png extension, e.g. 'cover.png'"),
    }),
  }
);

Two details worth noting: if the API returns nothing, we return an error string instead of throwing β€” the agent can read and react to that string, while an unhandled exception just kills the run. And the schema's .describe()

for prompt

β€” "subject, style, mood, colors, composition" β€” is teaching the agent how to write good image prompts through the schema itself. Prompt engineering all the way down.

Why wrap this tool in a dedicated agent instead of handing it straight to the supervisor? Because there's real intelligence between "here's an article" and "here's an image prompt." Someone has to read the article, distill its essence into a visual concept, and craft a detailed prompt β€” a specialist's job.

Create src/agents/image-agent.ts

:

import { createAgent } from "langchain";
import { creativeModel } from "../models.js";
import { saveMarkdown, readMarkdown } from "../tools/save-markdown.js";
import { generateImage } from "../tools/generate-image.js";

const IMAGE_PROMPT = `
You are an art director creating cover images for technical articles.

## Your task
1. Read the article using read_markdown ("article.md").
2. Distill its core theme into ONE strong visual concept.
3. Craft a detailed image prompt: subject, style, mood, colors, composition.
   Style guide: modern editorial illustration, clean, slightly abstract,
   NO text or words in the image.
4. Generate the image with generate_image as "cover.png".
5. Save a companion file with save_markdown as "cover.md" containing:
   ## Concept          (the visual idea in one sentence)
   ## Image Prompt     (the exact prompt you used)
   ## Alt Text         (one accessible sentence describing the image)

## Constraints
- One image only; make the single prompt count.
- After saving, reply with the concept in one sentence.
`.trim();

export const imageAgent = createAgent({
  model: creativeModel,
  tools: [readMarkdown, generateImage, saveMarkdown],
  systemPrompt: IMAGE_PROMPT,
});

Two details worth stealing: the style guide baked into the task ("modern editorial illustration, no text in the image") gives every cover a consistent brand and avoids image models' notorious typo-art. And the companion markdown file keeps the "everything is markdown" rule even for images β€” cover.md

records the concept, the exact prompt (for reproducibility), and alt text (accessibility built into the pipeline, not bolted on).

One agent left β€” social media β€” and a second multi-agent pattern to learn, because the supervisor pattern isn't always the right hammer.

A social media agent needs LinkedIn expertise today, but tomorrow you'll want X, then a newsletter, then Instagram. With sub-agents, that's a new agent per platform β€” a lot of ceremony for what is really the same job with different style rules.

Enter the skills pattern: ONE agent with a lightweight base prompt, plus a load_skill

tool that fetches deep, platform-specific expertise on demand. The agent decides which skill it needs, loads it into context, and applies it. This is called progressive disclosure β€” knowledge enters the context only when needed, keeping the base prompt small.

Rule of thumb: sub-agents when you need different tools, isolated contexts, or independence (like the fact-checker). Skills when it's one job with many flavors of expertise.

Create src/agents/social-agent.ts

:

import { createAgent, tool } from "langchain";
import z from "zod";
import { creativeModel } from "../models.js";
import { saveMarkdown, readMarkdown } from "../tools/save-markdown.js";

/**
 * SKILLS PATTERN β€” one agent, on-demand expertise.
 * Each skill is a rich platform-specific prompt. The agent loads the one
 * it needs via load_skill ("progressive disclosure"). Adding a platform
 * later = adding an entry here. No new agents, no new wiring.
 */
const SKILLS: Record<string, string> = {
  linkedin_post: `
    You are a LinkedIn content expert. Rules for a great teaser post:
    - Hook in the FIRST line β€” a bold claim or surprising fact from the article.
      (LinkedIn truncates after ~2 lines; the hook decides everything.)
    - 3-5 short paragraphs, one idea each. Generous line breaks.
    - Professional but human tone; no hype words ("game-changer", "πŸš€ excited").
    - One concrete insight from the article β€” give value before the ask.
    - End with a question to spark comments, then "Link in comments πŸ‘‡".
    - 3-5 niche hashtags at the end (not #technology β€” too broad).
  `.trim(),

  x_thread: `
    You are an X (Twitter) thread expert. Rules:
    - Tweet 1 is the hook: bold statement, under 200 chars, no hashtags.
    - 4-6 tweets, each self-contained, numbered "2/", "3/"...
    - Final tweet: summary + link placeholder.
  `.trim(),
};

const loadSkill = tool(
  ({ skillName }) => {
    const skill = SKILLS[skillName];
    if (!skill) {
      return `Unknown skill '${skillName}'. Available: ${Object.keys(SKILLS).join(", ")}`;
    }
    return skill;
  },
  {
    name: "load_skill",
    description:
      "Load platform-specific social media expertise. " +
      `Available skills: ${Object.keys(SKILLS).join(", ")}.`,
    schema: z.object({
      skillName: z.string().describe("Name of the skill to load"),
    }),
  }
);

const SOCIAL_PROMPT = `
You are a social media manager promoting technical articles.

## Your task
1. Read the article using read_markdown ("article.md").
2. Load the right platform skill with load_skill (default: linkedin_post).
3. Write the post following the loaded skill's rules exactly.
4. Save it with save_markdown as "linkedin-post.md" with this structure:
   ## Post              (the ready-to-publish post text)
   ## Best Time to Post (one-line suggestion)

## Constraints
- Base the post on the article's actual content β€” quote real insights.
- After saving, reply with just the hook line of the post.
`.trim();

export const socialAgent = createAgent({
  model: creativeModel,
  tools: [readMarkdown, loadSkill, saveMarkdown],
  systemPrompt: SOCIAL_PROMPT,
});

Flow: the agent reads the article, calls load_skill("linkedin_post")

β€” its context now contains that rich block of LinkedIn craft (the two-line hook rule, no-hype-words, niche hashtags) β€” and writes the post under those rules. Notice the unused x_thread

skill sitting right there: supporting X later means changing the instruction to the agent, not building anything new. In a real system these skill strings could live in separate files owned by a marketing team, updated without touching agent code β€” prompts as content, not code.

Two new wrapper tools β€” same recipe as Part 3: invoke with a fresh message, return the last message β€” plus an updated pipeline prompt.

In src/agents/supervisor.ts

, add the imports and wrappers:

import { imageAgent } from "./image-agent.js";
import { socialAgent } from "./social-agent.js";

const runImageCreator = tool(
  async () => {
    console.log("  🎨 Image agent working...");
    const result = await imageAgent.invoke({
      messages: [new HumanMessage("Create the cover image for article.md.")],
    });
    return result.messages.at(-1)?.content as string;
  },
  {
    name: "run_image_creator",
    description:
      "Create a cover image for article.md; saves cover.png and cover.md. " +
      "Requires run_writer to have completed first.",
    schema: z.object({}),
  }
);

const runSocialMedia = tool(
  async () => {
    console.log("  πŸ“£ Social media agent working...");
    const result = await socialAgent.invoke({
      messages: [new HumanMessage("Create a LinkedIn teaser for article.md.")],
    });
    return result.messages.at(-1)?.content as string;
  },
  {
    name: "run_social_media",
    description:
      "Write a LinkedIn teaser post for article.md; saves linkedin-post.md. " +
      "Requires run_writer to have completed first.",
    schema: z.object({}),
  }
);

Update the supervisor prompt and tools:

const SUPERVISOR_PROMPT = `
You are the editor-in-chief of a content studio, orchestrating a team
of specialist agents to produce a complete content package.

Your team (available as tools):
- run_researcher    β€” researches a topic, saves research.md
- run_writer        β€” writes the article, saves article.md
- run_fact_checker  β€” verifies the article, saves fact-check.md
- run_image_creator β€” creates a cover image, saves cover.png + cover.md
- run_social_media  β€” writes a LinkedIn teaser, saves linkedin-post.md

For every topic, run this pipeline IN ORDER:
1. run_researcher with the topic
2. run_writer with the topic
3. run_fact_checker
4. run_image_creator
5. run_social_media
6. Report to the user: article title, fact-check verdict, image concept,
   the post's hook line, and the full list of files produced.

Never skip a step. Never create content yourself β€” delegate everything.
If the fact-checker reports any FALSE claims, mention them prominently
in your final report so a human can review before publishing.
`.trim();

export const supervisor = createAgent({
  model,
  tools: [
    runResearcher,
    runWriter,
    runFactChecker,
    runImageCreator,
    runSocialMedia,
  ],
  systemPrompt: SUPERVISOR_PROMPT,
});

One quiet-but-important line: "If the fact-checker reports any FALSE claims, mention them prominently." The supervisor isn't just a scheduler β€” it reads each specialist's report and makes editorial judgments. That's the difference between a pipeline and an editor, and it's your human-in-the-loop hook: nothing publishes itself; a human reads the final report.

Polish src/index.ts

:

import { HumanMessage } from "@langchain/core/messages";
import { supervisor } from "./agents/supervisor.js";

const topic = process.argv[2];
if (!topic) {
  console.error('Usage: npm start -- "your topic here"');
  process.exit(1);
}

console.log(`πŸ“° Content Studio\n   Topic: ${topic}\n`);
console.time("Total time");

const result = await supervisor.invoke(
  { messages: [new HumanMessage(`Create a content package about: ${topic}`)] },
  { recursionLimit: 50 } // 5 sub-agent calls + reasoning turns need headroom
);

console.timeEnd("Total time");
console.log(`\n${result.messages.at(-1)?.content}`);

recursionLimit: 50

matters: each sub-agent call plus the supervisor's reasoning counts as steps, and the default limit is tight for a five-stage pipeline.

Run it:

npm start -- "How AI agents are changing software development"

This takes a few minutes β€” watch the progress logs: researcher… writer… fact-checker… image agent… social agent. Five specialists, each a plain createAgent

, each running its own tool loop in a clean context, coordinated by a sixth agent whose only tools are its team.

When it's done, open every file in output/

, including cover.png

: a research brief, a full article, a fact-check report with verdicts, a real cover image with its prompt and alt text, and a LinkedIn post ready to paste. An entire content package from one command β€” and every byte of it is inspectable markdown on disk.

βœ… Checkpoint: the complete 5-agent content studio, running end to end from a single command.

What you built, part by part:

Part 1: model configuration withinitChatModel

, and the anatomy ofcreateAgent

β€” model, tools, prompt.Part 2: tools (built-in and custom), Zod schemas as documentation for the model, and the four-part prompt: role, task, output contract, constraints.Part 3: the supervisor pattern β€” sub-agents wrapped as tools, stateless and independent, with files as shared state.Part 4: wrapping any external API as a tool, and the skills pattern β€” one agent, progressive disclosure of expertise.

Notice what you never needed: no graph API, no database, no framework beyond two functions β€” createAgent

and tool()

. Multi-agent systems aren't exotic β€” they're well-prompted specialists with clean interfaces, composed exactly like you compose functions.

Ideas to keep exploring:

  • Add streaming so you can watch tokens arrive in real time.
  • Add a revision loop β€” let the supervisor send the article back to the writer when the fact-checker finds false claims.
  • Add a human-approval step before the social post goes out.
  • Add a new platform skill (e.g. x_thread

is already stubbed in β€” try using it).

Everything built so far runs from the command line: invoke the agent, print an answer, done. That's fine for a quick check, but it gives no visibility into how the agent got there β€” which tools it called, what it saw at each step, where it went wrong. As you add tools and multiple agents, console.log

stops being enough.

The LangGraph server solves this: a local dev server, spun up with one command, that loads your agent, exposes it over a local API, and connects it to LangGraph Studio β€” a visual UI in the browser. There you can send messages, watch steps execute in real time, inspect the full message list, and re-run or tweak a request without touching code.

Create langgraph.json

next to your project (pointing at the agent exported from src/index.ts

β€” adjust the path to whichever file currently exports an agent

):

{
  "node_version": "24",
  "graphs": {
    "agent": "./src/index.ts:agent"
  },
  "env": ".env",
  "dependencies": ["."],
  "image_distro": "wolfi"
}

Run it:

npx langgraphjs dev

This boots the server and opens Studio, where you can inspect your agent visually β€” especially useful once the agent starts calling tools and making decisions.

Prerequisite: Bonus A above β€” you need langgraph.json

in place and npx langgraphjs dev

running on http://localhost:2024

.

Studio is great for debugging, but it's your tool, not something you'd hand to an end user. The npx langgraphjs dev

server exposes a full REST API β€” the same one Studio talks to. LangChain's @langchain/react

package gives us a client for it: one hook, useStream

.

We'll build the smallest possible frontend that proves this: one file, plain CSS, no state management library.

By the end of this bonus:

content-studio/
└── ui/
    β”œβ”€β”€ index.html
    β”œβ”€β”€ App.tsx
    β”œβ”€β”€ vite.config.ts
    └── package.json

This UI is a separate Vite + React app β€” it never talks to OpenAI directly, only to our own dev server. It needs React, Vite, and @langchain/react

for the useStream

hook.

From the content-studio

root:

mkdir ui && cd ui
npm init -y
npm install react react-dom @langchain/react
npm install -D typescript vite @vitejs/plugin-react @types/react @types/react-dom

Edit ui/package.json

β€” add a dev script and mark it a module:

{
  "type": "module",
  "scripts": {
    "dev": "vite"
  }
}

Create ui/vite.config.ts

:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
});

Create ui/index.html

:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Content Studio Chat</title>
  </head>
  <body style="margin:0">
    <div id="root"></div>
    <script type="module" src="/App.tsx"></script>
  </body>
</html>

App.tsx

renders a message list and an input box; useStream

supplies everything else β€” the messages array, a flag, and a submit

function. No fetch

, no manual SSE parsing, no reducer.

Create ui/App.tsx

:

import React, { useState } from "react";
import ReactDOM from "react-dom/client";
import { useStream } from "@langchain/react";

function App() {
  const [input, setInput] = useState("");

  // useStream talks directly to the `npx langgraphjs dev` server β€”
  // the same server and the same "agent" graph that Studio uses.
  const { messages, is, submit } = useStream({
    apiUrl: "http://localhost:2024",
    assistantId: "agent",
    messagesKey: "messages",
  });

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!input.trim() || is) return;
    submit({ messages: [{ type: "human", content: input }] });
    setInput("");
  }

  return (
    <>
      <style>{`
        body { font-family: system-ui, sans-serif; background: #f5f5f7; }
        .chat { max-width: 560px; margin: 40px auto; background: #fff;
          border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);
          display: flex; flex-direction: column; height: 80vh; }
        .messages { flex: 1; overflow-y: auto; padding: 20px; }
        .bubble { max-width: 75%; margin: 6px 0; padding: 10px 14px;
          border-radius: 14px; line-height: 1.4; white-space: pre-wrap; }
        .human { margin-left: auto; background: #2563eb; color: #fff; }
        .ai { background: #eee; color: #111; }
        .composer { display: flex; gap: 8px; padding: 16px; border-top: 1px solid #eee; }
        .composer input { flex: 1; padding: 10px 12px; border: 1px solid #ddd;
          border-radius: 8px; font-size: 14px; }
        .composer button { padding: 10px 18px; border: none; border-radius: 8px;
          background: #2563eb; color: #fff; font-weight: 600; cursor: pointer; }
        .composer button:disabled { opacity: 0.5; cursor: not-allowed; }
      `}</style>

      <div className="chat">
        <div className="messages">
          {messages.map((m) => (
            <div key={m.id} className={`bubble ${m.type === "human" ? "human" : "ai"}`}>
              {typeof m.content === "string" ? m.content : JSON.stringify(m.content)}
            </div>
          ))}
          {is && <div className="bubble ai">…</div>}
        </div>

        <form className="composer" onSubmit={handleSubmit}>
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Ask the content strategist…"
            disabled={is}
            autoFocus
          />
          <button type="submit" disabled={is || !input.trim()}>
            Send
          </button>
        </form>
      </div>
    </>
  );
}

ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

Three useStream

options to know:

apiUrl

β€” where your dev server is listening:http://localhost:2024

.assistantId

β€” must match a key inlanggraph.json

'sgraphs

object; ours is"agent"

.messagesKey

β€” which field of the agent's state holds the message list. Our agent's state is{ messages: [...] }

, exactly whatcreateAgent

expects, so"messages"

matches out of the box.

From there, submit()

sends a new human message, and the messages

array updates live as the agent streams its answer back β€” token by token β€” with zero streaming logic written by hand.

Two terminals, same as any full-stack app.

Terminal 1 (the agent server, from Bonus A):

npx langgraphjs dev

Terminal 2 (this UI):

cd ui
npm run dev

Open the printed Vite URL (typically http://localhost:5173

). Type a topic, hit Send, and watch the response stream in.

Nothing about src/index.ts

or models.ts

needs to change β€” this is the payoff of building on createAgent

and the LangGraph server from the start: the moment you need a real UI, you're not rewriting your agent, you're just pointing useStream

at it.

That's it β€” you built a full multi-agent content studio from scratch. πŸŽ‰

── more in #developer-tools 4 stories Β· sorted by recency
── more on @langchain 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/full-md] indexed:0 read:36min 2026-08-28 Β· β€”