{"slug": "full-md", "title": "full.md", "summary": "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.", "body_md": "**A hands-on coding exercise for the \"React Ladies\" community workshop.**\n\nIn this lab you'll build a **multi-agent content creation app** from an empty folder, live, using LangChain's `createAgent`\n\n— 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.\n\n| Agent | Powered by | Output |\n|---|---|---|\n| Research agent | Tavily web search | `output/research.md` |\n| Technical writer agent | OpenAI chat model | `output/article.md` |\n| Image agent | OpenAI Images API (`gpt-image-1` ) |\n`output/cover.png` + `output/cover.md` |\n| Fact-checker agent | Tavily web search | `output/fact-check.md` |\n| Social media agent | Skills pattern (platform prompts loaded on demand) | `output/linkedin-post.md` |\n\nThis 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.\n\n- Node.js ≥ 20 installed\n- A code editor (VS Code / WebStorm)\n- Basic TypeScript knowledge\n- Two free-tier API keys — get these before we start (instructions below):\n- OpenAI\n- Tavily\n\n- Go to\n[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\n**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\n**Settings → API keys**(or[platform.openai.com/api-keys](https://platform.openai.com/api-keys)). - Click\n**Create new secret key**, give it a name (e.g.`react-ladies-lab`\n\n), and click**Create**. **Copy the key immediately**— OpenAI only shows it once. It starts with`sk-`\n\n.\n\n- Go to\n[app.tavily.com](https://app.tavily.com)and sign up (free tier available, no card required). - Once logged in, your\n**API key** is shown right on the dashboard homepage. It starts with`tvly-`\n\n. - Copy it — you can always come back to the dashboard to view it again.\n\nKeep both keys handy — we'll paste them into a `.env`\n\nfile in Part 1, Step 1.3.\n\n**Goal:** a working TypeScript project with an OpenAI chat model wired up, and a first agent you can talk to from the terminal.\n\n**By the end of Part 1:**\n\n```\ncontent-studio/\n├── .env\n├── .gitignore\n├── package.json\n├── tsconfig.json\n└── src/\n    ├── models.ts\n    └── index.ts\nmkdir content-studio && cd content-studio\nnpm init -y\nnpm install langchain @langchain/openai @langchain/core @langchain/tavily zod dotenv\nnpm install -D typescript tsx @types/node\n```\n\n`langchain`\n\nis the main package — that's where `createAgent`\n\nlives. `@langchain/openai`\n\nis the OpenAI provider. `@langchain/tavily`\n\ngives us web search (Part 2). `zod`\n\ndescribes tool inputs (Part 2). `dotenv`\n\nloads our API keys from `.env`\n\n. `tsx`\n\nruns TypeScript files directly — no compile step, perfect for fast iteration.\n\nOpen `package.json`\n\nand add:\n\n```\n{\n  \"type\": \"module\",\n  \"scripts\": {\n    \"start\": \"tsx src/index.ts\"\n  }\n}\n```\n\n`\"type\": \"module\"`\n\ntells Node we're using ES modules (`import`\n\n/`export`\n\n), which is what lets us use top-level `await`\n\nlater.\n\nCreate `tsconfig.json`\n\n:\n\n```\n{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"types\": [\"node\"],\n    \"outDir\": \"dist\"\n  },\n  \"include\": [\"src\"]\n}\n```\n\n`NodeNext`\n\nmodule resolution matches our ES-module setup, `strict`\n\nkeeps type-checking honest, and `target: ES2022`\n\ngives us top-level await.\n\nCreate `.gitignore`\n\n**first**, before adding any real keys:\n\n```\nnode_modules/\ndist/\n.env\noutput/\n```\n\nCreate `.env`\n\nand paste in the two keys you got above:\n\n```\nOPENAI_API_KEY=sk-...\nTAVILY_API_KEY=tvly-...\n```\n\nThe variable names matter — LangChain's OpenAI and Tavily integrations look for exactly `OPENAI_API_KEY`\n\nand `TAVILY_API_KEY`\n\nin the environment. If the names match, everything wires up automatically; we never pass keys around in code. `output/`\n\nis also in `.gitignore`\n\n— that's where our agents will write their markdown files.\n\n`initChatModel`\n\nis 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.\n\nCreate `src/models.ts`\n\n:\n\n``` js\nimport \"dotenv/config\";\nimport { initChatModel } from \"langchain\";\n\n/**\n * Model configuration\n * -------------------\n * initChatModel is LangChain's universal model factory: pass a model name,\n * get back a standardized chat model. Provider is inferred from the name.\n *\n * Key options:\n *   temperature — creativity dial. 0 = deterministic, 1 = creative.\n *   maxTokens   — hard cap on response length.\n *   timeout     — ms before a hanging request is aborted.\n */\n\n// Balanced model for reasoning-heavy work (research, orchestration)\nexport const model = await initChatModel(\"gpt-4o\", {\n  temperature: 0.3,\n  timeout: 60_000,\n});\n\n// Creative model for writing tasks (articles, social posts)\nexport const creativeModel = await initChatModel(\"gpt-4o\", {\n  temperature: 0.8,\n  maxTokens: 4000,\n  timeout: 60_000,\n});\n```\n\n`import \"dotenv/config\"`\n\nat the top loads `.env`\n\nbefore anything else runs. We export **two** models from the same underlying `gpt-4o`\n\n, 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`\n\nworks because of our ES-modules setup.\n\nAn 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`\n\npackages 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.\n\nCreate `src/index.ts`\n\n:\n\n``` js\nimport { createAgent } from \"langchain\";\nimport { HumanMessage } from \"@langchain/core/messages\";\nimport { model } from \"./models.js\";\n\n/**\n * Our first agent: model + system prompt. No tools yet.\n * The system prompt defines WHO the agent is and HOW it should behave.\n */\nconst agent = createAgent({\n  model,\n  tools: [],\n  systemPrompt:\n    \"You are a content strategist for a technology blog. \" +\n    \"When given a topic, respond with a one-paragraph angle for an article: \" +\n    \"who the audience is, what the hook is, and why now. Be concrete.\",\n});\n\n// Read the topic from the command line\nconst topic = process.argv[2] ?? \"The rise of edge computing\";\n\nconst result = await agent.invoke({\n  messages: [new HumanMessage(topic)],\n});\n\n// The result is a list of messages; the agent's answer is the last one\nconsole.log(result.messages.at(-1)?.content);\n```\n\nNotice 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.\n\n`invoke`\n\ntakes a list of messages — here a single `HumanMessage`\n\nwith 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)`\n\n— an expression you'll type constantly in this lab.\n\nRun it:\n\n```\nnpm start -- \"WebAssembly on the server\"\n```\n\nTry running it again with the same topic to see the response vary. Optionally swap `model`\n\nfor `creativeModel`\n\nin `index.ts`\n\nand re-run to see the tone shift.\n\nThis will matter a lot once we add tools. Temporarily change the last line of `src/index.ts`\n\n:\n\n``` js\nfor (const message of result.messages) {\n  console.log(`[${message.getType()}]`, String(message.content).slice(0, 100));\n}\n```\n\nRun again — you'll see `[human]`\n\nfollowed by `[ai]`\n\n. 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.\n\nRevert to printing just the last message before moving on:\n\n```\nconsole.log(result.messages.at(-1)?.content);\n```\n\n**✅ Checkpoint:** you have a working TypeScript project, two configured models, and a first agent that responds on the command line.\n\n**Goal:** learn how tools work (`tool()`\n\n+ 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`\n\n.\n\n**By the end of Part 2:**\n\n```\ncontent-studio/\n└── src/\n    ├── models.ts\n    ├── tools/\n    │   └── save-markdown.ts\n    ├── agents/\n    │   └── research-agent.ts\n    └── index.ts\noutput/\n└── research.md        ← generated!\n```\n\nA 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.\n\nTavily is a search API built for LLMs — it returns clean, summarized results instead of raw HTML. We already installed `@langchain/tavily`\n\nand set `TAVILY_API_KEY`\n\nin Part 1, so wiring it up is two lines.\n\nCreate `src/agents/research-agent.ts`\n\nand start with:\n\n``` js\nimport { createAgent } from \"langchain\";\nimport { TavilySearch } from \"@langchain/tavily\";\nimport { model } from \"../models.js\";\n\n// Pre-built tool: web search designed for LLMs.\n// maxResults keeps responses focused (and cheap).\nconst searchWeb = new TavilySearch({ maxResults: 5, name: \"search_web\" });\n```\n\n`maxResults: 5`\n\ncaps how many results the agent reads (every result costs tokens). `name: \"search_web\"`\n\nmatters 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.\n\nOur 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()`\n\nfunction 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()`\n\nis a sentence of documentation for the AI. You're writing an API for a language model.\n\nCreate `src/tools/save-markdown.ts`\n\n:\n\n``` js\nimport { tool } from \"langchain\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport z from \"zod\";\n\nconst OUTPUT_DIR = \"output\";\n\n/**\n * Custom tool: saves markdown content to the output/ folder.\n * The Zod schema is converted to a spec the model reads — every\n * .describe() is documentation for the AI, not just validation.\n */\nexport const saveMarkdown = tool(\n  async ({ filename, content }) => {\n    await mkdir(OUTPUT_DIR, { recursive: true });\n    const filePath = path.join(OUTPUT_DIR, filename);\n    await writeFile(filePath, content, \"utf-8\");\n    return `Saved ${content.length} characters to ${filePath}`;\n  },\n  {\n    name: \"save_markdown\",\n    description:\n      \"Save markdown content to a file in the output folder. \" +\n      \"Use this to persist your final work product.\",\n    schema: z.object({\n      filename: z\n        .string()\n        .describe(\"File name including the .md extension, e.g. 'research.md'\"),\n      content: z.string().describe(\"The full markdown content to save\"),\n    }),\n  }\n);\n```\n\nThe implementation is plain Node: ensure `output/`\n\nexists, 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()`\n\non every field, including an example filename.\n\nA 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).\n\nContinue in `src/agents/research-agent.ts`\n\n:\n\n``` js\nimport { saveMarkdown } from \"../tools/save-markdown.js\";\n\n/**\n * Prompt anatomy: ROLE → TASK → OUTPUT CONTRACT → CONSTRAINTS.\n * Template literals keep long prompts readable.\n */\nconst RESEARCH_PROMPT = `\nYou are a senior research analyst for a technology publication.\nYour research is thorough, current, and always source-backed.\n\n## Your task\nGiven a topic:\n1. Run 2-3 web searches covering different angles of the topic\n   (state of the art, real-world adoption, criticism/challenges).\n2. Synthesize the findings into a research brief.\n3. Save the brief using the save_markdown tool as \"research.md\".\n\n## Output contract — research.md must contain exactly these sections:\n# Research Brief: <topic>\n## Key Findings        (5-8 bullet points, each with a concrete fact)\n## Notable Sources     (list of URLs found during search)\n## Suggested Angle     (one paragraph: the most compelling story here)\n\n## Constraints\n- Every key finding must come from search results, not prior knowledge.\n- Include specific numbers, dates, and names wherever possible.\n- After saving the file, reply with a 2-3 sentence summary of what you found.\n`.trim();\n\nexport const researchAgent = createAgent({\n  model,\n  tools: [searchWeb, saveMarkdown],\n  systemPrompt: RESEARCH_PROMPT,\n});\n```\n\nThe 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.\n\nReplace `src/index.ts`\n\n:\n\n``` js\nimport { HumanMessage } from \"@langchain/core/messages\";\nimport { researchAgent } from \"./agents/research-agent.js\";\n\nconst topic = process.argv[2] ?? \"The rise of edge computing\";\n\nconsole.log(`🔎 Researching: ${topic}\\n`);\n\nconst result = await researchAgent.invoke({\n  messages: [new HumanMessage(`Research this topic: ${topic}`)],\n});\n\nconsole.log(result.messages.at(-1)?.content);\nnpm start -- \"WebAssembly on the server\"\n```\n\nWhile it runs: the agent decides on its own to call `search_web`\n\n(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`\n\n, gets the confirmation string back, and writes its final summary reply.\n\nOpen `output/research.md`\n\nand check the structure matches the output contract's headings exactly.\n\nOptionally, add this debugging view to watch the agent think → act → observe:\n\n``` js\n// Debugging view: watch the agent think → act → observe\nfor (const message of result.messages) {\n  const type = message.getType();\n  const preview = String(message.content).slice(0, 120);\n  console.log(`[${type}] ${preview}\\n`);\n}\n```\n\nYou'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.\n\n- Temporarily replace\n`RESEARCH_PROMPT`\n\nwith`\"You are a helpful research assistant. Save your findings as markdown.\"`\n\n, run, and compare`research.md`\n\n: 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.\n\nSame model, same tools — dramatically different quality. When an agent underperforms, your first suspect is never the model. It's the prompt.\n\n**✅ Checkpoint:** your research agent searches the web and produces a well-structured `research.md`\n\n.\n\n**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.\n\n**By the end of Part 3:**\n\n```\ncontent-studio/\n└── src/\n    ├── models.ts\n    ├── tools/\n    │   └── save-markdown.ts\n    ├── agents/\n    │   ├── research-agent.ts\n    │   ├── writer-agent.ts\n    │   ├── fact-checker-agent.ts\n    │   └── supervisor.ts\n    └── index.ts\noutput/\n├── research.md\n├── article.md         ← new!\n└── fact-check.md      ← new!\n```\n\nWhy not just keep adding tools and instructions to one agent until it does everything? Three reasons:\n\n**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.\n\nThe 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.\n\nSub-agents are **stateless** — every call starts fresh; only the supervisor keeps history.\n\nSub-agents pass work to each other *through the markdown files* — the researcher writes `research.md`\n\n, the writer will read it. Add a read-back tool next to the save tool.\n\nAdd to `src/tools/save-markdown.ts`\n\n:\n\n``` js\nimport { readFile } from \"node:fs/promises\";\n\nexport const readMarkdown = tool(\n  async ({ filename }) => {\n    const filePath = path.join(OUTPUT_DIR, filename);\n    return await readFile(filePath, \"utf-8\");\n  },\n  {\n    name: \"read_markdown\",\n    description:\n      \"Read a markdown file from the output folder. \" +\n      \"Use this to load work produced by previous steps.\",\n    schema: z.object({\n      filename: z.string().describe(\"File name to read, e.g. 'research.md'\"),\n    }),\n  }\n);\n```\n\nThe 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.\n\nThe 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`\n\nand `save_markdown`\n\n. This is also where `creativeModel`\n\nfrom Part 1 earns its place: temperature 0.8 for prose, vs. 0.3 for the researcher's facts.\n\nCreate `src/agents/writer-agent.ts`\n\n:\n\n``` js\nimport { createAgent } from \"langchain\";\nimport { creativeModel } from \"../models.js\";\nimport { saveMarkdown, readMarkdown } from \"../tools/save-markdown.js\";\n\nconst WRITER_PROMPT = `\nYou are a senior technical writer for a respected technology publication.\nYour writing is clear, engaging, and precise — never marketing fluff.\n\n## Your task\n1. Read the research brief using read_markdown (\"research.md\").\n2. Write a complete technical article based ONLY on that research.\n3. Save it using save_markdown as \"article.md\".\n\n## Output contract — article.md structure:\n# <Compelling, specific title>\n*<One-sentence subtitle>*\n## Introduction        (hook the reader, why this matters now)\n## <2-4 body sections with descriptive headings>\n## Conclusion          (takeaways + a forward-looking closing thought)\n\n## Constraints\n- 800-1200 words.\n- Every factual claim must come from the research brief.\n- Explain technical terms on first use; assume a smart but busy reader.\n- After saving, reply with the article title and a one-line description.\n`.trim();\n\nexport const writerAgent = createAgent({\n  model: creativeModel,\n  tools: [readMarkdown, saveMarkdown],\n  systemPrompt: WRITER_PROMPT,\n});\n```\n\nSame 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.\n\nThis 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.\n\nCreate `src/agents/fact-checker-agent.ts`\n\n:\n\n``` js\nimport { createAgent } from \"langchain\";\nimport { TavilySearch } from \"@langchain/tavily\";\nimport { model } from \"../models.js\";\nimport { saveMarkdown, readMarkdown } from \"../tools/save-markdown.js\";\n\nconst factCheckWeb = new TavilySearch({ maxResults: 3, name: \"fact_check_web\" });\n\nconst FACT_CHECKER_PROMPT = `\nYou are a meticulous fact-checker. You trust nothing without a source.\n\n## Your task\n1. Read the article using read_markdown (\"article.md\").\n2. Extract the 4-6 most important factual claims.\n3. Verify each claim with fact_check_web searches.\n4. Save your report using save_markdown as \"fact-check.md\".\n\n## Output contract — fact-check.md structure:\n# Fact-Check Report\n## Verdict Summary     (one line: how many verified / unverified / false)\n## Claims\nFor each claim:\n### Claim: \"<the claim>\"\n- **Verdict:** ✅ Verified | ⚠️ Unverified | ❌ False\n- **Evidence:** <what you found, with source URL>\n\n## Constraints\n- Check claims independently — do not assume the article is correct.\n- If a claim is False, quote the correct information with its source.\n- After saving, reply with the verdict summary line only.\n`.trim();\n\nexport const factCheckerAgent = createAgent({\n  model,\n  tools: [readMarkdown, factCheckWeb, saveMarkdown],\n  systemPrompt: FACT_CHECKER_PROMPT,\n});\n```\n\nThis 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.\n\nWe have three specialist agents. To let a supervisor use them, wrap each one in a `tool()`\n\n— the same `tool()`\n\nused 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.\n\nCreate `src/agents/supervisor.ts`\n\n:\n\n``` js\nimport { createAgent, tool } from \"langchain\";\nimport { HumanMessage } from \"@langchain/core/messages\";\nimport z from \"zod\";\nimport { model } from \"../models.js\";\nimport { researchAgent } from \"./research-agent.js\";\nimport { writerAgent } from \"./writer-agent.js\";\nimport { factCheckerAgent } from \"./fact-checker-agent.js\";\n\n/**\n * SUB-AGENTS AS TOOLS (supervisor pattern)\n * ----------------------------------------\n * Each wrapper: (1) invokes the sub-agent with a fresh HumanMessage,\n * (2) returns only the last message — the sub-agent's final answer.\n * Sub-agents are STATELESS: every call starts with a clean context.\n * The supervisor is the only agent holding the full conversation.\n */\n\nconst runResearcher = tool(\n  async ({ topic }) => {\n    console.log(\"  🔎 Research agent working...\");\n    const result = await researchAgent.invoke({\n      messages: [new HumanMessage(`Research this topic: ${topic}`)],\n    });\n    return result.messages.at(-1)?.content as string;\n  },\n  {\n    name: \"run_researcher\",\n    description:\n      \"Research a topic on the web and save a research brief to research.md. \" +\n      \"Returns a summary of the findings.\",\n    schema: z.object({ topic: z.string().describe(\"The topic to research\") }),\n  }\n);\n\nconst runWriter = tool(\n  async ({ topic }) => {\n    console.log(\"  ✍️  Writer agent working...\");\n    const result = await writerAgent.invoke({\n      messages: [\n        new HumanMessage(\n          `Write the article about \"${topic}\" based on research.md.`\n        ),\n      ],\n    });\n    return result.messages.at(-1)?.content as string;\n  },\n  {\n    name: \"run_writer\",\n    description:\n      \"Write a technical article from research.md and save it to article.md. \" +\n      \"Requires run_researcher to have completed first.\",\n    schema: z.object({ topic: z.string().describe(\"The article topic\") }),\n  }\n);\n\nconst runFactChecker = tool(\n  async () => {\n    console.log(\"  ✅ Fact-checker agent working...\");\n    const result = await factCheckerAgent.invoke({\n      messages: [new HumanMessage(\"Fact-check the article in article.md.\")],\n    });\n    return result.messages.at(-1)?.content as string;\n  },\n  {\n    name: \"run_fact_checker\",\n    description:\n      \"Verify the claims in article.md and save a report to fact-check.md. \" +\n      \"Requires run_writer to have completed first. Returns the verdict summary.\",\n    schema: z.object({}),\n  }\n);\n```\n\nThree 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.\n\nThe supervisor is just another `createAgent`\n\n— 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.\n\nContinue in `src/agents/supervisor.ts`\n\n:\n\n``` js\nconst SUPERVISOR_PROMPT = `\nYou are the editor-in-chief of a content studio, orchestrating a team\nof specialist agents to produce a complete content package.\n\nYour team (available as tools):\n- run_researcher   — researches a topic, saves research.md\n- run_writer       — writes the article from research, saves article.md\n- run_fact_checker — verifies the article's claims, saves fact-check.md\n\nFor every topic, run this pipeline IN ORDER:\n1. run_researcher with the topic\n2. run_writer with the topic\n3. run_fact_checker\n4. Report to the user: the article title, the fact-check verdict summary,\n   and the list of files produced.\n\nNever skip a step. Never write content yourself — delegate everything.\n`.trim();\n\nexport const supervisor = createAgent({\n  model,\n  tools: [runResearcher, runWriter, runFactChecker],\n  systemPrompt: SUPERVISOR_PROMPT,\n});\n```\n\nUpdate `src/index.ts`\n\n:\n\n``` js\nimport { HumanMessage } from \"@langchain/core/messages\";\nimport { supervisor } from \"./agents/supervisor.js\";\n\nconst topic = process.argv[2] ?? \"The rise of edge computing\";\n\nconsole.log(`📰 Content studio starting on: ${topic}\\n`);\n\nconst result = await supervisor.invoke({\n  messages: [new HumanMessage(`Create a content package about: ${topic}`)],\n});\n\nconsole.log(`\\n${result.messages.at(-1)?.content}`);\n```\n\nOne 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.\n\n```\nnpm start -- \"Post-quantum cryptography migration\"\n```\n\nWatch the progress logs appear in order. When it finishes, open `output/`\n\nand look at `research.md`\n\n, `article.md`\n\n, and `fact-check.md`\n\n— 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.\n\n**✅ Checkpoint:** a 3-agent pipeline — researcher → writer → fact-checker — running end to end, coordinated by a supervisor agent.\n\n**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.\n\n**By the end of Part 4 — the final app:**\n\n```\ncontent-studio/\n└── src/\n    ├── models.ts\n    ├── tools/\n    │   ├── save-markdown.ts\n    │   └── generate-image.ts\n    ├── agents/\n    │   ├── research-agent.ts\n    │   ├── writer-agent.ts\n    │   ├── fact-checker-agent.ts\n    │   ├── image-agent.ts\n    │   ├── social-agent.ts\n    │   └── supervisor.ts\n    └── index.ts\noutput/\n├── research.md\n├── article.md\n├── fact-check.md\n├── cover.png          ← new!\n├── cover.md            ← new!\n└── linkedin-post.md   ← new!\n```\n\nChat models talk; image models paint. OpenAI's `gpt-image-1`\n\nisn't a chat model, so `initChatModel`\n\ndoesn't apply — instead we call the Images API directly with the `openai`\n\nSDK 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()`\n\nwith a good schema.\n\nInstall the SDK:\n\n```\nnpm install openai\n```\n\nCreate `src/tools/generate-image.ts`\n\n:\n\n``` python\nimport { tool } from \"langchain\";\nimport OpenAI from \"openai\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport z from \"zod\";\n\nconst openai = new OpenAI(); // reads OPENAI_API_KEY from env\nconst OUTPUT_DIR = \"output\";\n\n/**\n * Custom tool wrapping the OpenAI Images API.\n * Any external API becomes agent-usable with tool() + a clear schema.\n */\nexport const generateImage = tool(\n  async ({ prompt, filename }) => {\n    const response = await openai.images.generate({\n      model: \"gpt-image-1\",\n      prompt,\n      size: \"1536x1024\", // landscape — good for article covers\n    });\n\n    const b64 = response.data?.[0]?.b64_json;\n    if (!b64) return \"Image generation failed: no image data returned.\";\n\n    await mkdir(OUTPUT_DIR, { recursive: true });\n    const filePath = path.join(OUTPUT_DIR, filename);\n    await writeFile(filePath, Buffer.from(b64, \"base64\"));\n    return `Image saved to ${filePath}`;\n  },\n  {\n    name: \"generate_image\",\n    description:\n      \"Generate an image from a text prompt using an AI image model \" +\n      \"and save it as a PNG file in the output folder.\",\n    schema: z.object({\n      prompt: z\n        .string()\n        .describe(\n          \"Detailed visual description: subject, style, mood, colors, composition\"\n        ),\n      filename: z.string().describe(\"File name with .png extension, e.g. 'cover.png'\"),\n    }),\n  }\n);\n```\n\nTwo 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()`\n\nfor `prompt`\n\n— \"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.\n\nWhy 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.\n\nCreate `src/agents/image-agent.ts`\n\n:\n\n``` js\nimport { createAgent } from \"langchain\";\nimport { creativeModel } from \"../models.js\";\nimport { saveMarkdown, readMarkdown } from \"../tools/save-markdown.js\";\nimport { generateImage } from \"../tools/generate-image.js\";\n\nconst IMAGE_PROMPT = `\nYou are an art director creating cover images for technical articles.\n\n## Your task\n1. Read the article using read_markdown (\"article.md\").\n2. Distill its core theme into ONE strong visual concept.\n3. Craft a detailed image prompt: subject, style, mood, colors, composition.\n   Style guide: modern editorial illustration, clean, slightly abstract,\n   NO text or words in the image.\n4. Generate the image with generate_image as \"cover.png\".\n5. Save a companion file with save_markdown as \"cover.md\" containing:\n   # Cover Image\n   ## Concept          (the visual idea in one sentence)\n   ## Image Prompt     (the exact prompt you used)\n   ## Alt Text         (one accessible sentence describing the image)\n\n## Constraints\n- One image only; make the single prompt count.\n- After saving, reply with the concept in one sentence.\n`.trim();\n\nexport const imageAgent = createAgent({\n  model: creativeModel,\n  tools: [readMarkdown, generateImage, saveMarkdown],\n  systemPrompt: IMAGE_PROMPT,\n});\n```\n\nTwo 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`\n\nrecords the concept, the exact prompt (for reproducibility), and alt text (accessibility built into the pipeline, not bolted on).\n\nOne agent left — social media — and a second multi-agent pattern to learn, because the supervisor pattern isn't always the right hammer.\n\nA 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*.\n\nEnter the **skills pattern**: ONE agent with a lightweight base prompt, plus a `load_skill`\n\ntool 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.\n\nRule 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.\n\nCreate `src/agents/social-agent.ts`\n\n:\n\n``` python\nimport { createAgent, tool } from \"langchain\";\nimport z from \"zod\";\nimport { creativeModel } from \"../models.js\";\nimport { saveMarkdown, readMarkdown } from \"../tools/save-markdown.js\";\n\n/**\n * SKILLS PATTERN — one agent, on-demand expertise.\n * Each skill is a rich platform-specific prompt. The agent loads the one\n * it needs via load_skill (\"progressive disclosure\"). Adding a platform\n * later = adding an entry here. No new agents, no new wiring.\n */\nconst SKILLS: Record<string, string> = {\n  linkedin_post: `\n    You are a LinkedIn content expert. Rules for a great teaser post:\n    - Hook in the FIRST line — a bold claim or surprising fact from the article.\n      (LinkedIn truncates after ~2 lines; the hook decides everything.)\n    - 3-5 short paragraphs, one idea each. Generous line breaks.\n    - Professional but human tone; no hype words (\"game-changer\", \"🚀 excited\").\n    - One concrete insight from the article — give value before the ask.\n    - End with a question to spark comments, then \"Link in comments 👇\".\n    - 3-5 niche hashtags at the end (not #technology — too broad).\n  `.trim(),\n\n  x_thread: `\n    You are an X (Twitter) thread expert. Rules:\n    - Tweet 1 is the hook: bold statement, under 200 chars, no hashtags.\n    - 4-6 tweets, each self-contained, numbered \"2/\", \"3/\"...\n    - Final tweet: summary + link placeholder.\n  `.trim(),\n};\n\nconst loadSkill = tool(\n  ({ skillName }) => {\n    const skill = SKILLS[skillName];\n    if (!skill) {\n      return `Unknown skill '${skillName}'. Available: ${Object.keys(SKILLS).join(\", \")}`;\n    }\n    return skill;\n  },\n  {\n    name: \"load_skill\",\n    description:\n      \"Load platform-specific social media expertise. \" +\n      `Available skills: ${Object.keys(SKILLS).join(\", \")}.`,\n    schema: z.object({\n      skillName: z.string().describe(\"Name of the skill to load\"),\n    }),\n  }\n);\n\nconst SOCIAL_PROMPT = `\nYou are a social media manager promoting technical articles.\n\n## Your task\n1. Read the article using read_markdown (\"article.md\").\n2. Load the right platform skill with load_skill (default: linkedin_post).\n3. Write the post following the loaded skill's rules exactly.\n4. Save it with save_markdown as \"linkedin-post.md\" with this structure:\n   # LinkedIn Teaser\n   ## Post              (the ready-to-publish post text)\n   ## Best Time to Post (one-line suggestion)\n\n## Constraints\n- Base the post on the article's actual content — quote real insights.\n- After saving, reply with just the hook line of the post.\n`.trim();\n\nexport const socialAgent = createAgent({\n  model: creativeModel,\n  tools: [readMarkdown, loadSkill, saveMarkdown],\n  systemPrompt: SOCIAL_PROMPT,\n});\n```\n\nFlow: the agent reads the article, calls `load_skill(\"linkedin_post\")`\n\n— 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`\n\nskill 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.\n\nTwo new wrapper tools — same recipe as Part 3: invoke with a fresh message, return the last message — plus an updated pipeline prompt.\n\nIn `src/agents/supervisor.ts`\n\n, add the imports and wrappers:\n\n``` js\nimport { imageAgent } from \"./image-agent.js\";\nimport { socialAgent } from \"./social-agent.js\";\n\nconst runImageCreator = tool(\n  async () => {\n    console.log(\"  🎨 Image agent working...\");\n    const result = await imageAgent.invoke({\n      messages: [new HumanMessage(\"Create the cover image for article.md.\")],\n    });\n    return result.messages.at(-1)?.content as string;\n  },\n  {\n    name: \"run_image_creator\",\n    description:\n      \"Create a cover image for article.md; saves cover.png and cover.md. \" +\n      \"Requires run_writer to have completed first.\",\n    schema: z.object({}),\n  }\n);\n\nconst runSocialMedia = tool(\n  async () => {\n    console.log(\"  📣 Social media agent working...\");\n    const result = await socialAgent.invoke({\n      messages: [new HumanMessage(\"Create a LinkedIn teaser for article.md.\")],\n    });\n    return result.messages.at(-1)?.content as string;\n  },\n  {\n    name: \"run_social_media\",\n    description:\n      \"Write a LinkedIn teaser post for article.md; saves linkedin-post.md. \" +\n      \"Requires run_writer to have completed first.\",\n    schema: z.object({}),\n  }\n);\n```\n\nUpdate the supervisor prompt and tools:\n\n``` js\nconst SUPERVISOR_PROMPT = `\nYou are the editor-in-chief of a content studio, orchestrating a team\nof specialist agents to produce a complete content package.\n\nYour team (available as tools):\n- run_researcher    — researches a topic, saves research.md\n- run_writer        — writes the article, saves article.md\n- run_fact_checker  — verifies the article, saves fact-check.md\n- run_image_creator — creates a cover image, saves cover.png + cover.md\n- run_social_media  — writes a LinkedIn teaser, saves linkedin-post.md\n\nFor every topic, run this pipeline IN ORDER:\n1. run_researcher with the topic\n2. run_writer with the topic\n3. run_fact_checker\n4. run_image_creator\n5. run_social_media\n6. Report to the user: article title, fact-check verdict, image concept,\n   the post's hook line, and the full list of files produced.\n\nNever skip a step. Never create content yourself — delegate everything.\nIf the fact-checker reports any FALSE claims, mention them prominently\nin your final report so a human can review before publishing.\n`.trim();\n\nexport const supervisor = createAgent({\n  model,\n  tools: [\n    runResearcher,\n    runWriter,\n    runFactChecker,\n    runImageCreator,\n    runSocialMedia,\n  ],\n  systemPrompt: SUPERVISOR_PROMPT,\n});\n```\n\nOne 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.\n\nPolish `src/index.ts`\n\n:\n\n``` js\nimport { HumanMessage } from \"@langchain/core/messages\";\nimport { supervisor } from \"./agents/supervisor.js\";\n\nconst topic = process.argv[2];\nif (!topic) {\n  console.error('Usage: npm start -- \"your topic here\"');\n  process.exit(1);\n}\n\nconsole.log(`📰 Content Studio\\n   Topic: ${topic}\\n`);\nconsole.time(\"Total time\");\n\nconst result = await supervisor.invoke(\n  { messages: [new HumanMessage(`Create a content package about: ${topic}`)] },\n  { recursionLimit: 50 } // 5 sub-agent calls + reasoning turns need headroom\n);\n\nconsole.timeEnd(\"Total time\");\nconsole.log(`\\n${result.messages.at(-1)?.content}`);\n```\n\n`recursionLimit: 50`\n\nmatters: each sub-agent call plus the supervisor's reasoning counts as steps, and the default limit is tight for a five-stage pipeline.\n\nRun it:\n\n```\nnpm start -- \"How AI agents are changing software development\"\n```\n\nThis takes a few minutes — watch the progress logs: researcher… writer… fact-checker… image agent… social agent. Five specialists, each a plain `createAgent`\n\n, each running its own tool loop in a clean context, coordinated by a sixth agent whose only tools are its team.\n\nWhen it's done, open every file in `output/`\n\n, including `cover.png`\n\n: 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.\n\n**✅ Checkpoint:** the complete 5-agent content studio, running end to end from a single command.\n\nWhat you built, part by part:\n\n**Part 1:** model configuration with`initChatModel`\n\n, and the anatomy of`createAgent`\n\n— 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.\n\nNotice what you never needed: no graph API, no database, no framework beyond two functions — `createAgent`\n\nand `tool()`\n\n. Multi-agent systems aren't exotic — they're well-prompted specialists with clean interfaces, composed exactly like you compose functions.\n\n**Ideas to keep exploring:**\n\n- Add streaming so you can watch tokens arrive in real time.\n- Add a revision loop — let the supervisor send the article back to the writer when the fact-checker finds false claims.\n- Add a human-approval step before the social post goes out.\n- Add a new platform skill (e.g.\n`x_thread`\n\nis already stubbed in — try using it).\n\nEverything 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`\n\nstops being enough.\n\nThe **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.\n\nCreate `langgraph.json`\n\nnext to your project (pointing at the agent exported from `src/index.ts`\n\n— adjust the path to whichever file currently exports an `agent`\n\n):\n\n```\n{\n  \"node_version\": \"24\",\n  \"graphs\": {\n    \"agent\": \"./src/index.ts:agent\"\n  },\n  \"env\": \".env\",\n  \"dependencies\": [\".\"],\n  \"image_distro\": \"wolfi\"\n}\n```\n\nRun it:\n\n```\nnpx langgraphjs dev\n```\n\nThis boots the server and opens Studio, where you can inspect your agent visually — especially useful once the agent starts calling tools and making decisions.\n\n**Prerequisite:** Bonus A above — you need `langgraph.json`\n\nin place and `npx langgraphjs dev`\n\nrunning on `http://localhost:2024`\n\n.\n\nStudio is great for debugging, but it's *your* tool, not something you'd hand to an end user. The `npx langgraphjs dev`\n\nserver exposes a full REST API — the same one Studio talks to. LangChain's `@langchain/react`\n\npackage gives us a client for it: one hook, `useStream`\n\n.\n\nWe'll build the smallest possible frontend that proves this: one file, plain CSS, no state management library.\n\n**By the end of this bonus:**\n\n```\ncontent-studio/\n└── ui/\n    ├── index.html\n    ├── App.tsx\n    ├── vite.config.ts\n    └── package.json\n```\n\nThis 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`\n\nfor the `useStream`\n\nhook.\n\nFrom the `content-studio`\n\nroot:\n\n```\nmkdir ui && cd ui\nnpm init -y\nnpm install react react-dom @langchain/react\nnpm install -D typescript vite @vitejs/plugin-react @types/react @types/react-dom\n```\n\nEdit `ui/package.json`\n\n— add a dev script and mark it a module:\n\n```\n{\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\"\n  }\n}\n```\n\nCreate `ui/vite.config.ts`\n\n:\n\n``` python\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\nexport default defineConfig({\n  plugins: [react()],\n});\n```\n\nCreate `ui/index.html`\n\n:\n\n```\n<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>Content Studio Chat</title>\n  </head>\n  <body style=\"margin:0\">\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/App.tsx\"></script>\n  </body>\n</html>\n```\n\n`App.tsx`\n\nrenders a message list and an input box; `useStream`\n\nsupplies everything else — the messages array, a loading flag, and a `submit`\n\nfunction. No `fetch`\n\n, no manual SSE parsing, no reducer.\n\nCreate `ui/App.tsx`\n\n:\n\n``` python\nimport React, { useState } from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport { useStream } from \"@langchain/react\";\n\nfunction App() {\n  const [input, setInput] = useState(\"\");\n\n  // useStream talks directly to the `npx langgraphjs dev` server —\n  // the same server and the same \"agent\" graph that Studio uses.\n  const { messages, isLoading, submit } = useStream({\n    apiUrl: \"http://localhost:2024\",\n    assistantId: \"agent\",\n    messagesKey: \"messages\",\n  });\n\n  function handleSubmit(e: React.FormEvent) {\n    e.preventDefault();\n    if (!input.trim() || isLoading) return;\n    submit({ messages: [{ type: \"human\", content: input }] });\n    setInput(\"\");\n  }\n\n  return (\n    <>\n      <style>{`\n        body { font-family: system-ui, sans-serif; background: #f5f5f7; }\n        .chat { max-width: 560px; margin: 40px auto; background: #fff;\n          border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);\n          display: flex; flex-direction: column; height: 80vh; }\n        .messages { flex: 1; overflow-y: auto; padding: 20px; }\n        .bubble { max-width: 75%; margin: 6px 0; padding: 10px 14px;\n          border-radius: 14px; line-height: 1.4; white-space: pre-wrap; }\n        .human { margin-left: auto; background: #2563eb; color: #fff; }\n        .ai { background: #eee; color: #111; }\n        .composer { display: flex; gap: 8px; padding: 16px; border-top: 1px solid #eee; }\n        .composer input { flex: 1; padding: 10px 12px; border: 1px solid #ddd;\n          border-radius: 8px; font-size: 14px; }\n        .composer button { padding: 10px 18px; border: none; border-radius: 8px;\n          background: #2563eb; color: #fff; font-weight: 600; cursor: pointer; }\n        .composer button:disabled { opacity: 0.5; cursor: not-allowed; }\n      `}</style>\n\n      <div className=\"chat\">\n        <div className=\"messages\">\n          {messages.map((m) => (\n            <div key={m.id} className={`bubble ${m.type === \"human\" ? \"human\" : \"ai\"}`}>\n              {typeof m.content === \"string\" ? m.content : JSON.stringify(m.content)}\n            </div>\n          ))}\n          {isLoading && <div className=\"bubble ai\">…</div>}\n        </div>\n\n        <form className=\"composer\" onSubmit={handleSubmit}>\n          <input\n            value={input}\n            onChange={(e) => setInput(e.target.value)}\n            placeholder=\"Ask the content strategist…\"\n            disabled={isLoading}\n            autoFocus\n          />\n          <button type=\"submit\" disabled={isLoading || !input.trim()}>\n            Send\n          </button>\n        </form>\n      </div>\n    </>\n  );\n}\n\nReactDOM.createRoot(document.getElementById(\"root\")!).render(<App />);\n```\n\nThree `useStream`\n\noptions to know:\n\n`apiUrl`\n\n— where your dev server is listening:`http://localhost:2024`\n\n.`assistantId`\n\n— must match a key in`langgraph.json`\n\n's`graphs`\n\nobject; ours is`\"agent\"`\n\n.`messagesKey`\n\n— which field of the agent's state holds the message list. Our agent's state is`{ messages: [...] }`\n\n, exactly what`createAgent`\n\nexpects, so`\"messages\"`\n\nmatches out of the box.\n\nFrom there, `submit()`\n\nsends a new human message, and the `messages`\n\narray updates live as the agent streams its answer back — token by token — with zero streaming logic written by hand.\n\nTwo terminals, same as any full-stack app.\n\nTerminal 1 (the agent server, from Bonus A):\n\n```\nnpx langgraphjs dev\n```\n\nTerminal 2 (this UI):\n\n```\ncd ui\nnpm run dev\n```\n\nOpen the printed Vite URL (typically `http://localhost:5173`\n\n). Type a topic, hit Send, and watch the response stream in.\n\nNothing about `src/index.ts`\n\nor `models.ts`\n\nneeds to change — this is the payoff of building on `createAgent`\n\nand the LangGraph server from the start: the moment you need a real UI, you're not rewriting your agent, you're just pointing `useStream`\n\nat it.\n\n**That's it — you built a full multi-agent content studio from scratch. 🎉**", "url": "https://wpnews.pro/news/full-md", "canonical_source": "https://gist.github.com/nirkaufman/6b1c79632b7324c278f7b08feb6cc08d", "published_at": "2026-08-28 09:28:42+00:00", "updated_at": "2026-08-28 17:48:54.304043+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["LangChain", "OpenAI", "Tavily", "React Ladies"], "alternates": {"html": "https://wpnews.pro/news/full-md", "markdown": "https://wpnews.pro/news/full-md.md", "text": "https://wpnews.pro/news/full-md.txt", "jsonld": "https://wpnews.pro/news/full-md.jsonld"}}