LangGraph graphs explained: eight small lessons

17 min read
On this page

This post is about LangGraph graphs.

A graph sounds fancy. It is not. A graph is three things:

  • State — some shared data.
  • Nodes — functions that change the state.
  • Edges — arrows that say which node runs next.

That’s the whole idea. Everything below is those three things in different shapes.

I’ll go through eight small lessons. Each one adds one idea. By the end you have a single file you can run, one lesson at a time. The first six lessons need no API key at all.

Why a graph?

An agent is not a straight line. It loops. It branches. It waits.

A graph is a simple way to draw those shapes and then run them. You describe the boxes and the arrows. LangGraph walks them for you.

Here is the whole idea in one picture:

        ┌──────── shared state ────────┐
        │     { name, steps, ... }     │
        └──────────────────────────────┘
             ▲         ▲         ▲
             │         │         │   every node reads and writes it
          ┌──┴──┐   ┌──┴──┐   ┌──┴──┐
  START ─►  node ─►  node ─►  node  ─► END
          └─────┘   └─────┘   └─────┘
                  arrows = edges

The pieces you’ll use

A few names from LangGraph show up again and again:

  • StateGraph — the blueprint. You add nodes and edges to it.
  • Annotation.Root — defines the state and its fields.
  • addNode(name, fn) — add a worker function.
  • addEdge(a, b) — after a, always go to b.
  • addConditionalEdges(a, fn) — after a, let fn pick where to go.
  • START / END — the entry and the exit.
  • compile() — turn the blueprint into something runnable.
  • invoke(input) — run it.

Two ready-made helpers come in later:

  • MessagesAnnotation — state with a messages list, built in.
  • ToolNode / toolsCondition — a node that runs tools, and a router that checks if the model asked for one.

What you need

  • Node.js 20.6 or newer. Check with node --version.
  • An OpenRouter API key from openrouter.ai, but only for lessons 7 and 8. Lessons 1 to 6 run offline.
  • Four npm packages. Next step.

Setup

mkdir graph-demo && cd graph-demo
npm init -y
npm pkg set type=module
npm install @langchain/langgraph @langchain/core @langchain/openrouter zod

Shared pieces

Every lesson shares these few lines at the top.

import { tool } from "@langchain/core/tools";
import {
  Annotation,
  END,
  MessagesAnnotation,
  START,
  StateGraph,
} from "@langchain/langgraph";
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";
import { ChatOpenRouter } from "@langchain/openrouter";
import * as z from "zod";

// Paste your key here. Only lessons 7 and 8 use it.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";

const title = (t) =>
  console.log(`\n${"=".repeat(60)}\n${t}\n${"=".repeat(60)}`);

// Made only when a lesson needs it, so lessons 1-6 run with no key.
const getModel = () =>
  new ChatOpenRouter({ model: "openai/gpt-4o-mini", temperature: 0 });

Now the lessons.

Lesson 1: state

State is the shared memory. Every node can read it. Every node can change it.

Each field has a rule for how changes are applied. The rule is called a reducer.

  • No reducer means “last value wins”. The new value replaces the old one.
  • A reducer like concat means “add to the list”. The new value is appended.
   old value   +   new value   ──►  reducer  ──►  field value

   name  (no reducer):  "Bob"  then  "Carol"      → "Carol"
   steps (concat):      ["first"] + ["second"]    → ["first", "second"]

Here are two fields, one of each kind.

const L1State = Annotation.Root({
  // No reducer = last value wins (REPLACE)
  name: Annotation(),

  // With reducer = custom rule. Here: append to the list
  steps: Annotation({
    reducer: (oldValue, newValue) => oldValue.concat(newValue),
    default: () => [],
  }),
});

async function lesson1() {
  title("LESSON 1: State (replace vs append)");

  const graph = new StateGraph(L1State)
    .addNode("first", (state) => {
      console.log("first sees:", state);
      return { name: "Bob", steps: ["first"] };
    })
    .addNode("second", (state) => {
      console.log("second sees:", state);
      return { name: "Carol", steps: ["second"] };
    })
    .addEdge(START, "first")
    .addEdge("first", "second")
    .addEdge("second", END)
    .compile();

  const result = await graph.invoke({ name: "Alice" });
  console.log("Final state:", result);
  // { name: "Carol", steps: ["first", "second"] }
}

Watch what happens to each field as the two nodes run:

   start:   name = "Alice"     steps = []

   first    name  → "Bob"      (replaced)
            steps → ["first"]  (appended)

   second   name  → "Carol"               (replaced)
            steps → ["first", "second"]   (appended)

name keeps only the last value. steps keeps them all. Same graph, two different rules.

Lesson 2: nodes

A node is just a function.

  • It takes the state.
  • It returns the fields it wants to change.
  • Return only what changed. LangGraph merges it in for you.
  • It can be sync or async.
const L2State = Annotation.Root({
  text: Annotation(),
  wordCount: Annotation(),
});

// Node 1: clean the text (sync)
const cleanNode = (state) => {
  return { text: state.text.trim().toLowerCase() };
};

// Node 2: count words (async, like a real API call)
const countNode = async (state) => {
  await new Promise((r) => setTimeout(r, 300)); // pretend work
  return { wordCount: state.text.split(/\s+/).length };
};

async function lesson2() {
  title("LESSON 2: Nodes are just functions");

  const graph = new StateGraph(L2State)
    .addNode("clean", cleanNode)
    .addNode("count", countNode)
    .addEdge(START, "clean")
    .addEdge("clean", "count")
    .addEdge("count", END)
    .compile();

  const result = await graph.invoke({ text: "   Hello LangGraph World   " });
  console.log(result); // { text: "hello langgraph world", wordCount: 3 }
}

cleanNode only touches text. countNode only touches wordCount. Neither one has to know about the other.

Lesson 3: edges make a line

Edges connect nodes.

  • addEdge(a, b) means “after a, always go to b”.
  • START is the entry. END is the exit.
  • StateGraph is the blueprint. compile() makes it runnable. invoke() runs it.

The simplest graph is a straight line.

   START ─► A ─► B ─► C ─► END
const L3State = Annotation.Root({
  order: Annotation({ reducer: (a, b) => a.concat(b), default: () => [] }),
});

async function lesson3() {
  title("LESSON 3: Linear graph  START → A → B → C → END");

  const builder = new StateGraph(L3State) // 1. blueprint
    .addNode("A", () => ({ order: ["A"] })) // 2. nodes
    .addNode("B", () => ({ order: ["B"] }))
    .addNode("C", () => ({ order: ["C"] }))
    .addEdge(START, "A") // 3. edges
    .addEdge("A", "B")
    .addEdge("B", "C")
    .addEdge("C", END);

  const graph = builder.compile(); // 4. compile
  const result = await graph.invoke({}); // 5. run

  console.log("Visited:", result.order.join(" → ")); // A → B → C

  // Bonus: LangGraph can draw itself. Paste this into mermaid.live
  console.log("\nDiagram:\n" + graph.getGraph().drawMermaid());
}

The five steps at the bottom are the whole LangGraph workflow: blueprint, nodes, edges, compile, run. Every lesson follows them.

That last line is a nice trick. graph.getGraph().drawMermaid() prints a diagram of your graph. More on that near the end.

Lesson 4: conditional edges make a branch

A normal edge always goes the same way. A conditional edge decides at runtime.

  • You give addConditionalEdges a function.
  • The function looks at the state.
  • It returns the name of the next node.
             ┌─ age >= 18 ─► adult ─► END
   START ─► check
             └─ age <  18 ─► child ─► END
const L4State = Annotation.Root({
  age: Annotation(),
  message: Annotation(),
});

function routeByAge(state) {
  return state.age >= 18 ? "adult" : "child"; // returns a node name
}

async function lesson4() {
  title("LESSON 4: Branching graph (if / else)");

  const graph = new StateGraph(L4State)
    .addNode("check", (state) => {
      console.log(`Checking age ${state.age}...`);
      return {}; // no change
    })
    .addNode("adult", () => ({ message: "Welcome, you can enter 🍺" }))
    .addNode("child", () => ({ message: "Sorry, juice only 🧃" }))
    .addEdge(START, "check")
    .addConditionalEdges("check", routeByAge, ["adult", "child"])
    .addEdge("adult", END)
    .addEdge("child", END)
    .compile();

  console.log((await graph.invoke({ age: 25 })).message);
  console.log((await graph.invoke({ age: 10 })).message);
}

A branch is just an if written as arrows.

Lesson 5: loops

A conditional edge can point back to an earlier node. That makes a loop.

The node runs again and again, until the function says stop.

   START ─► increment
               │
          count < 3 ?
           │       │
          yes      no
           │       │
           │       ▼
           │      END
           └─► back to increment   (the loop)
const L5State = Annotation.Root({
  count: Annotation({ reducer: (_, b) => b, default: () => 0 }),
});

async function lesson5() {
  title("LESSON 5: Loop graph (repeat until done)");

  const graph = new StateGraph(L5State)
    .addNode("increment", (state) => {
      console.log("count =", state.count + 1);
      return { count: state.count + 1 };
    })
    .addEdge(START, "increment")
    .addConditionalEdges(
      "increment",
      (state) => (state.count < 3 ? "increment" : END), // loop back OR exit
    )
    .compile();

  const result = await graph.invoke({ count: 0 });
  console.log("Done. Final count:", result.count);
}

This is how an agent “keeps thinking”. It loops until it has an answer.

Lesson 6: parallel work

One node can lead to many nodes. They run at the same time. That’s fan-out.

Many nodes can lead to one node. It waits for all of them. That’s fan-in.

            ┌─► weather ─┐
            │            │
   START ───┼─► news ────┼─► combine ─► END
            │            │
            └─► stocks ──┘

   the three run together;
   combine waits for all three
const L6State = Annotation.Root({
  results: Annotation({ reducer: (a, b) => a.concat(b), default: () => [] }),
  summary: Annotation(),
});

const slowTask = (name, ms) => async () => {
  console.log(`  ${name} started`);
  await new Promise((r) => setTimeout(r, ms));
  console.log(`  ${name} finished`);
  return { results: [`${name} data`] };
};

async function lesson6() {
  title("LESSON 6: Parallel graph (fan-out / fan-in)");

  const graph = new StateGraph(L6State)
    .addNode("weather", slowTask("weather", 500))
    .addNode("news", slowTask("news", 300))
    .addNode("stocks", slowTask("stocks", 400))
    .addNode("combine", (state) => ({ summary: state.results.join(" + ") }))
    // fan-out: START → 3 nodes
    .addEdge(START, "weather")
    .addEdge(START, "news")
    .addEdge(START, "stocks")
    // fan-in: 3 nodes → combine
    .addEdge("weather", "combine")
    .addEdge("news", "combine")
    .addEdge("stocks", "combine")
    .addEdge("combine", END)
    .compile();

  console.time("total");
  const result = await graph.invoke({});
  console.timeEnd("total"); // ~500ms, not 1200ms → it ran in parallel
  console.log("Summary:", result.summary);
}

The total time is about 500ms, not 1200ms. The three tasks really did run together.

One thing to notice: all three write to results. That field needs a reducer, so the writes add up instead of overwriting each other. Without it, you’d keep only one.

Lesson 7: a messages state

Chat apps keep a list of messages. LangGraph has a ready-made state for that, so you don’t build it yourself.

  • MessagesAnnotation gives you a messages field.
  • Its reducer appends new messages to the list.
  • You can add your own fields next to it.
   messages: [ user: "Say hi in 5 words." ]
          │
          ▼
       chatbot   ── calls the model, appends the reply
          │
          ▼
   messages: [ user: "Say hi...", ai: "Hi there, nice to meet you" ]
const ChatState = Annotation.Root({
  ...MessagesAnnotation.spec, // gives you `messages`
  turns: Annotation({ reducer: (a, b) => a + b, default: () => 0 }),
});

async function lesson7() {
  title("LESSON 7: MessagesState + a real LLM (OpenRouter)");
  const model = getModel();

  const graph = new StateGraph(ChatState)
    .addNode("chatbot", async (state) => {
      const reply = await model.invoke(state.messages);
      return { messages: [reply], turns: 1 }; // append reply, add 1 turn
    })
    .addEdge(START, "chatbot")
    .addEdge("chatbot", END)
    .compile();

  const result = await graph.invoke({
    messages: [{ role: "user", content: "Say hi in 5 words." }],
  });

  result.messages.forEach((m) => console.log(`${m.getType()}: ${m.content}`));
  console.log("Turns:", result.turns);
}

This lesson calls a real model, so it needs your key.

Lesson 8: your own agent graph

Now put it all together. Messages, a conditional edge, and a loop.

  • The agent node calls the model.
  • If the model asks for a tool, go to the tools node, then loop back.
  • If it doesn’t, go to END.
   START
     │
     ▼
   agent ◄───────────────┐
     │                   │
  tool calls?            │
   │        │            │
  yes       no           │
   │        │            │
   ▼        ▼            │
  tools    END           │
   │                     │
   └──── back to agent ──┘
const getWeather = tool(async ({ city }) => `It's sunny in ${city}`, {
  name: "get_weather",
  description: "Get weather for a city",
  schema: z.object({ city: z.string() }),
});

async function lesson8() {
  title("LESSON 8: Build your own agent graph");
  const tools = [getWeather];
  const modelWithTools = getModel().bindTools(tools);

  const graph = new StateGraph(MessagesAnnotation)
    .addNode("agent", async (state) => {
      const reply = await modelWithTools.invoke(state.messages);
      return { messages: [reply] };
    })
    .addNode("tools", new ToolNode(tools))
    .addEdge(START, "agent")
    .addConditionalEdges("agent", toolsCondition) // → "tools" or END
    .addEdge("tools", "agent") // loop back
    .compile();

  const result = await graph.invoke({
    messages: [{ role: "user", content: "Weather in Paris?" }],
  });

  result.messages.forEach((m) =>
    console.log(`${m.getType()}: ${m.content || JSON.stringify(m.tool_calls)}`),
  );
}

toolsCondition is a ready-made router. It checks the model’s reply. If the model asked for a tool, it routes to tools. If not, it routes to END.

This little graph is basically what createAgent builds for you. If you want to add your own code around this loop, that’s what middleware is for — I wrote about that in the middleware post.

The whole thing: one file

Everything above in one file. Save it as graph-tutorial.mjs.

// graph-tutorial.mjs
import { tool } from "@langchain/core/tools";
import {
  Annotation,
  END,
  MessagesAnnotation,
  START,
  StateGraph,
} from "@langchain/langgraph";
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";
import { ChatOpenRouter } from "@langchain/openrouter";
import * as z from "zod";

// Paste your key here. Only lessons 7 and 8 use it.
process.env.OPENROUTER_API_KEY = "sk-or-v1-paste-your-key-here";

const title = (t) =>
  console.log(`\n${"=".repeat(60)}\n${t}\n${"=".repeat(60)}`);

const getModel = () =>
  new ChatOpenRouter({ model: "openai/gpt-4o-mini", temperature: 0 });

// ---------------------------------------------------------------------
//  LESSON 1: State (replace vs append)
// ---------------------------------------------------------------------
const L1State = Annotation.Root({
  name: Annotation(),
  steps: Annotation({
    reducer: (oldValue, newValue) => oldValue.concat(newValue),
    default: () => [],
  }),
});

async function lesson1() {
  title("LESSON 1: State (replace vs append)");

  const graph = new StateGraph(L1State)
    .addNode("first", (state) => {
      console.log("first sees:", state);
      return { name: "Bob", steps: ["first"] };
    })
    .addNode("second", (state) => {
      console.log("second sees:", state);
      return { name: "Carol", steps: ["second"] };
    })
    .addEdge(START, "first")
    .addEdge("first", "second")
    .addEdge("second", END)
    .compile();

  const result = await graph.invoke({ name: "Alice" });
  console.log("Final state:", result);
}

// ---------------------------------------------------------------------
//  LESSON 2: Nodes are just functions
// ---------------------------------------------------------------------
const L2State = Annotation.Root({
  text: Annotation(),
  wordCount: Annotation(),
});

const cleanNode = (state) => {
  return { text: state.text.trim().toLowerCase() };
};

const countNode = async (state) => {
  await new Promise((r) => setTimeout(r, 300));
  return { wordCount: state.text.split(/\s+/).length };
};

async function lesson2() {
  title("LESSON 2: Nodes are just functions");

  const graph = new StateGraph(L2State)
    .addNode("clean", cleanNode)
    .addNode("count", countNode)
    .addEdge(START, "clean")
    .addEdge("clean", "count")
    .addEdge("count", END)
    .compile();

  const result = await graph.invoke({ text: "   Hello LangGraph World   " });
  console.log(result);
}

// ---------------------------------------------------------------------
//  LESSON 3: Linear graph
// ---------------------------------------------------------------------
const L3State = Annotation.Root({
  order: Annotation({ reducer: (a, b) => a.concat(b), default: () => [] }),
});

async function lesson3() {
  title("LESSON 3: Linear graph  START → A → B → C → END");

  const builder = new StateGraph(L3State)
    .addNode("A", () => ({ order: ["A"] }))
    .addNode("B", () => ({ order: ["B"] }))
    .addNode("C", () => ({ order: ["C"] }))
    .addEdge(START, "A")
    .addEdge("A", "B")
    .addEdge("B", "C")
    .addEdge("C", END);

  const graph = builder.compile();
  const result = await graph.invoke({});

  console.log("Visited:", result.order.join(" → "));
  console.log("\nDiagram:\n" + graph.getGraph().drawMermaid());
}

// ---------------------------------------------------------------------
//  LESSON 4: Branching graph (if / else)
// ---------------------------------------------------------------------
const L4State = Annotation.Root({
  age: Annotation(),
  message: Annotation(),
});

function routeByAge(state) {
  return state.age >= 18 ? "adult" : "child";
}

async function lesson4() {
  title("LESSON 4: Branching graph (if / else)");

  const graph = new StateGraph(L4State)
    .addNode("check", (state) => {
      console.log(`Checking age ${state.age}...`);
      return {};
    })
    .addNode("adult", () => ({ message: "Welcome, you can enter 🍺" }))
    .addNode("child", () => ({ message: "Sorry, juice only 🧃" }))
    .addEdge(START, "check")
    .addConditionalEdges("check", routeByAge, ["adult", "child"])
    .addEdge("adult", END)
    .addEdge("child", END)
    .compile();

  console.log((await graph.invoke({ age: 25 })).message);
  console.log((await graph.invoke({ age: 10 })).message);
}

// ---------------------------------------------------------------------
//  LESSON 5: Loop graph (repeat until done)
// ---------------------------------------------------------------------
const L5State = Annotation.Root({
  count: Annotation({ reducer: (_, b) => b, default: () => 0 }),
});

async function lesson5() {
  title("LESSON 5: Loop graph (repeat until done)");

  const graph = new StateGraph(L5State)
    .addNode("increment", (state) => {
      console.log("count =", state.count + 1);
      return { count: state.count + 1 };
    })
    .addEdge(START, "increment")
    .addConditionalEdges(
      "increment",
      (state) => (state.count < 3 ? "increment" : END),
    )
    .compile();

  const result = await graph.invoke({ count: 0 });
  console.log("Done. Final count:", result.count);
}

// ---------------------------------------------------------------------
//  LESSON 6: Parallel graph (fan-out / fan-in)
// ---------------------------------------------------------------------
const L6State = Annotation.Root({
  results: Annotation({ reducer: (a, b) => a.concat(b), default: () => [] }),
  summary: Annotation(),
});

const slowTask = (name, ms) => async () => {
  console.log(`  ${name} started`);
  await new Promise((r) => setTimeout(r, ms));
  console.log(`  ${name} finished`);
  return { results: [`${name} data`] };
};

async function lesson6() {
  title("LESSON 6: Parallel graph (fan-out / fan-in)");

  const graph = new StateGraph(L6State)
    .addNode("weather", slowTask("weather", 500))
    .addNode("news", slowTask("news", 300))
    .addNode("stocks", slowTask("stocks", 400))
    .addNode("combine", (state) => ({ summary: state.results.join(" + ") }))
    .addEdge(START, "weather")
    .addEdge(START, "news")
    .addEdge(START, "stocks")
    .addEdge("weather", "combine")
    .addEdge("news", "combine")
    .addEdge("stocks", "combine")
    .addEdge("combine", END)
    .compile();

  console.time("total");
  const result = await graph.invoke({});
  console.timeEnd("total");
  console.log("Summary:", result.summary);
}

// ---------------------------------------------------------------------
//  LESSON 7: MessagesState + a real LLM
// ---------------------------------------------------------------------
const ChatState = Annotation.Root({
  ...MessagesAnnotation.spec,
  turns: Annotation({ reducer: (a, b) => a + b, default: () => 0 }),
});

async function lesson7() {
  title("LESSON 7: MessagesState + a real LLM (OpenRouter)");
  const model = getModel();

  const graph = new StateGraph(ChatState)
    .addNode("chatbot", async (state) => {
      const reply = await model.invoke(state.messages);
      return { messages: [reply], turns: 1 };
    })
    .addEdge(START, "chatbot")
    .addEdge("chatbot", END)
    .compile();

  const result = await graph.invoke({
    messages: [{ role: "user", content: "Say hi in 5 words." }],
  });

  result.messages.forEach((m) => console.log(`${m.getType()}: ${m.content}`));
  console.log("Turns:", result.turns);
}

// ---------------------------------------------------------------------
//  LESSON 8: Build your own agent graph
// ---------------------------------------------------------------------
const getWeather = tool(async ({ city }) => `It's sunny in ${city}`, {
  name: "get_weather",
  description: "Get weather for a city",
  schema: z.object({ city: z.string() }),
});

async function lesson8() {
  title("LESSON 8: Build your own agent graph");
  const tools = [getWeather];
  const modelWithTools = getModel().bindTools(tools);

  const graph = new StateGraph(MessagesAnnotation)
    .addNode("agent", async (state) => {
      const reply = await modelWithTools.invoke(state.messages);
      return { messages: [reply] };
    })
    .addNode("tools", new ToolNode(tools))
    .addEdge(START, "agent")
    .addConditionalEdges("agent", toolsCondition)
    .addEdge("tools", "agent")
    .compile();

  const result = await graph.invoke({
    messages: [{ role: "user", content: "Weather in Paris?" }],
  });

  result.messages.forEach((m) =>
    console.log(`${m.getType()}: ${m.content || JSON.stringify(m.tool_calls)}`),
  );
}

// ---------------------------------------------------------------------
//  Runner
// ---------------------------------------------------------------------
const lessons = {
  1: lesson1,
  2: lesson2,
  3: lesson3,
  4: lesson4,
  5: lesson5,
  6: lesson6,
  7: lesson7,
  8: lesson8,
};

const arg = process.argv[2] ?? "1";
if (arg === "all") {
  for (const fn of Object.values(lessons)) await fn();
} else if (lessons[arg]) {
  await lessons[arg]();
} else {
  console.log("Usage: node graph-tutorial.mjs <1-8|all>");
}

Running it

Pick a lesson by number. Or run them all.

node graph-tutorial.mjs 1      # one lesson
node graph-tutorial.mjs all    # every lesson

Lessons 1 to 6 need no key. Lessons 7 and 8 call a real model, so paste your key first.

Let the graph draw itself

You don’t have to draw these diagrams by hand. LangGraph can draw a graph for you.

console.log(graph.getGraph().drawMermaid());

That prints Mermaid text. Copy it into mermaid.live and you see your graph as a real picture. It’s the fastest way to check that your nodes and edges connect the way you think they do.

About that hardcoded key

A key pasted into a script on your own machine is fine. A key in a script that ends up in a git repo is not. Bots scan public GitHub for keys.

Before you commit, drop the line that sets the key, and run like this:

node --env-file=.env graph-tutorial.mjs 7

Put OPENROUTER_API_KEY=sk-or-v1-... in a .env file. Add .env to .gitignore.

Where to go from here

  • Give the graph a memory. LangGraph can save state between runs with a checkpointer, so a chat remembers earlier turns.
  • Stream the run. graph.stream(...) gives you each step as it happens, not just the final state.
  • Add more branches. A router node can send work to one of many handlers.
  • Watch it think. Print the state inside each node while you build.
  • When your graph starts to look like lesson 8, reach for createAgent. It builds that loop for you, and middleware lets you change it.

That’s the whole idea. State, nodes, edges. Draw the shape, then run it.


Share: