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