# full.md

> Source: <https://gist.github.com/nirkaufman/6b1c79632b7324c278f7b08feb6cc08d>
> Published: 2026-08-28 09:28:42+00:00

**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.com](https://platform.openai.com)and 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**(or[platform.openai.com/api-keys](https://platform.openai.com/api-keys)). - Click
**Create new secret key**, give it a name (e.g.`react-ladies-lab`

), and click**Create**. **Copy the key immediately**— OpenAI only shows it once. It starts with`sk-`

.

- Go to
[app.tavily.com](https://app.tavily.com)and sign up (free tier available, no card required). - Once logged in, your
**API key** is shown right on the dashboard homepage. It starts with`tvly-`

. - 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`

:

``` js
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`

:

``` js
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`

:

``` js
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:

``` js
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`

:

``` js
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`

:

``` js
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:
# Research Brief: <topic>
## 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`

:

``` js
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:

``` js
// 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 compare`research.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`

:

``` js
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`

:

``` js
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:
# <Compelling, specific title>
*<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`

:

``` js
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:
# Fact-Check Report
## 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`

:

``` js
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`

:

``` js
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`

:

``` js
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`

:

``` python
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`

:

``` js
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:
   # Cover Image
   ## 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`

:

``` python
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:
   # LinkedIn Teaser
   ## 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:

``` js
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:

``` js
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`

:

``` js
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 with`initChatModel`

, and the anatomy of`createAgent`

— 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`

:

``` python
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 loading flag, and a `submit`

function. No `fetch`

, no manual SSE parsing, no reducer.

Create `ui/App.tsx`

:

``` python
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, isLoading, submit } = useStream({
    apiUrl: "http://localhost:2024",
    assistantId: "agent",
    messagesKey: "messages",
  });

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!input.trim() || isLoading) 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>
          ))}
          {isLoading && <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={isLoading}
            autoFocus
          />
          <button type="submit" disabled={isLoading || !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 in`langgraph.json`

's`graphs`

object; ours is`"agent"`

.`messagesKey`

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

, exactly what`createAgent`

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. 🎉**
