Orchestrated LangGraph Workflow in TypeScript

Four Agents, One Graph

A state graph with explicit nodes, conditional edges, and a built-in retry gate — using the same lightweight agents from the previous articles.

Every example so far in this series has used the same pattern: plain async functions calling the Chat Completions API in a while loop. No frameworks, no SDKs — just fetch(), a message list and plain functions.

This example introduces LangGraph, a minimal framework from LangChain that models workflows as a state graph: nodes are the work, edges are the control flow, and the state object is passed between them. The workflow is the same SEO content pipeline you have seen before — research, write, review, publish — but expressed as a compileable, inspectable graph rather than a chain of async function calls.

Everything is on GitHub at https://github.com/juustesout/typescript-agent-example-langgraph in the TSLangGraph folder.


1. Installatie — The One Dependency

Unlike the other examples in this series, LangGraph adds two npm packages:

npm install --save @langchain/langgraph @langchain/core
npm install --save-dev typescript @types/node

Everything else is the same setup: Node.js 18+, an OpenAI key, and DataForSEO + Jina + WordPress credentials in .env:

OPENAI_API_KEY=sk-proj-your-key-here
DATAFORSEO_API_KEY=your-dataforseo-key
JINA_API_KEY=your-jina-key
WP_BASE_URL=https://your-site.com
WP_USERNAME=your-wordpress-username
WP_APPLICATION_PASSWORD=your-wordpress-password
npm run build
npm run demo "AI sales agents for SMBs"

2. Project Structure

TSLangGraph/
├── src/
│   ├── graph.ts    # LangGraph state, nodes, edges, and the compiled graph
│   └── demo.ts     # CLI runner — invokes the graph and prints state
├── dist/
├── package.json    # langchain/langgraph + langchain/core
├── tsconfig.json
└── .env

Two source files, one graph. All the specialist agent logic — SEO research, writing, review, publishing — lives inside graph.ts as separate node functions.


3. State — The Core of a LangGraph Workflow

Every LangGraph workflow revolves around a single state object that passes through every node. Each node reads from the state and returns a partial update. The graph engine merges the updates and passes the new state to the next node.

type WorkflowState = {
  topic: string;                    // set by the caller
  research?: { ... };               // set by researchNode
  draft?: DraftArticle;             // set by writerNode
  review?: ReviewResult;            // set by reviewNode
  reviewAttempts: number;           // incremented on each revision loop
  publishPayload?: PublishPayload;  // set by publisherNode
  publishResult?: PublishResult;    // set by publisherNode
};

In LangGraph terms, this is declared via the Annotation.Root helper:

const WorkflowAnnotation = Annotation.Root({
  topic: Annotation<string>,
  research: Annotation<any>,
  draft: Annotation<any>,
  review: Annotation<any>,
  reviewAttempts: Annotation<number>,
  publishPayload: Annotation<any>,
  publishResult: Annotation<any>,
});

Each key in the annotation becomes a channel in the graph. Nodes can read any channel and write to any channel. The graph engine handles the merging.


4. The Nodes — What Each Step Does

Each node is an async function that receives the current state and returns a partial state update. They are identical in logic to the standalone runSeoResearch(), runWriter(), runReviewer() and runPublisher() functions from the TShandoff/TSworkflow examples — the only difference is the wrapper signature.

Research Node

async function researchNode(state: WorkflowState): Promise<Partial<WorkflowState>> {
  return { research: await runSeoResearch(state.topic) };
}

It reads state.topic, runs the full DataForSEO + Jina pipeline, and sets state.research with the brief. That is three lines for the node wrapper — the actual work is delegated to runSeoResearch().

Writer Node

The writer node is the most interesting because it handles both the initial write and revision passes. It checks whether the state already contains a rejected review — if so, it passes the feedback to the model and rewrites instead of writing from scratch:

async function writerNode(state: WorkflowState): Promise<Partial<WorkflowState>> {
  if (!state.research) throw new Error("Research must happen before writing.");

  if (state.review && !state.review.approved) {
    // Revision pass: use the review feedback to rewrite
    const revised = await callModelForJson(
      buildAgent(WRITER_PROMPT, { temperature: 0.7 }),
      `The draft was rejected. Rewrite using this feedback:
       ${JSON.stringify({ research: state.research,
         draft: state.draft, review: state.review })}`
    );
    return {
      draft: { ... revised ... },
      reviewAttempts: state.reviewAttempts + 1,
    };
  }

  // First pass: write from research
  return { draft: await runWriter(state.research) };
}

The node returns both the updated draft and the incremented reviewAttempts counter — the graph engine merges both into the state.

Review Node

async function reviewNode(state: WorkflowState): Promise<Partial<WorkflowState>> {
  if (!state.draft) throw new Error("Draft is missing before review.");
  return { review: await runReviewer(state.draft) };
}

Simple gate: read the draft, produce a ReviewResult with approved, score, issues and requiredChanges.

Publisher Node

async function publisherNode(state: WorkflowState): Promise<Partial<WorkflowState>> {
  if (!state.draft) throw new Error("Draft is missing before publishing.");
  const publishPayload = await runPublisher(state.draft);
  return {
    publishPayload,
    publishResult: await publishToWordPress(publishPayload),
  };
}

Formats the article and sends it to WordPress. Returns both the payload and the result so the caller can inspect exactly what was sent and what came back.


5. The Edges — Control Flow with Conditional Routing

Edges define the graph topology. Fixed edges (from START, between sequential nodes) and a conditional edge from the review node that branches based on the approved flag:

function routeAfterReview(state: WorkflowState): string {
  if (!state.review) return END;
  if (state.review.approved) return "publisher_step";
  if (state.reviewAttempts >= REVIEW_MAX_ATTEMPTS) return END;
  return "writer_step";
}

The routing function reads the state and returns the name of the next node. The graph wiring:

const workflow = new StateGraph(WorkflowAnnotation)
  .addNode("research_step", researchNode)
  .addNode("writer_step", writerNode)
  .addNode("review_step", reviewNode)
  .addNode("publisher_step", publisherNode)
  .addEdge(START, "research_step")
  .addEdge("research_step", "writer_step")
  .addEdge("writer_step", "review_step")
  .addConditionalEdges("review_step", routeAfterReview, {
    publisher: "publisher_step",
    writer: "writer_step",
    [END]: END,
  })
  .addEdge("publisher_step", END);

This is the complete graph definition. The resulting flow:

START → research → writer → review → (approved? publisher : writer or END)

The beauty of this representation is that it is visualisable. You can draw this graph, inspect it at runtime, and extend it by adding nodes and edges — without touching the existing node code.


6. Running the Graph

The compiled graph exposes a single invoke() method that accepts the initial state and returns the final state after all nodes have run:

export const app = workflow.compile();

export async function runWorkflowGraph(topic: string): Promise<WorkflowState> {
  const result = await app.invoke({ topic, reviewAttempts: 0 });
  return result as WorkflowState;
}
npm run build
node dist/demo.js "AI sales agents for SMBs"

The demo prints every field of the final state:

Starting LangGraph workflow...

Research: { "keyword": "...", "angle": "...", "audience": "...", "headings": ["..."] }
Draft: { "title": "...", "summary": "...", "slug": "..." }
Review: { "approved": true, "score": 92, "issues": [] }
Publish payload: { "title": "...", "status": "draft" }
Publish result: { "success": true, "postId": 1583,
  "url": "https://your-site.com/?p=1583" }

7. What LangGraph Adds — And What It Costs

After four examples without frameworks, this one adds a real one. The difference is instructive.

What it adds

  • Explicit graph structure — the nodes and edges are declared as a graph, not hidden inside async function calls. You can see the entire workflow in one block of code.
  • Conditional routing — the review → writer revision loop is a conditional edge, not a while loop in the orchestrator. The routing function is a pure function of state, which makes it testable in isolation.
  • State management — the graph engine merges partial state updates from each node. You never accidentally overwrite a field from a previous node because the merge is automatic.
  • Compile-time validationworkflow.compile() checks that all node names referenced in edges actually exist, that there are no cycles where there should not be, and that the state annotation keys are consistent.
  • Inspectability — LangGraph can log every state transition, every node execution time, and the final state. Debugging a failed run means replaying the graph.

What it costs

  • Two dependencies@langchain/langgraph and @langchain/core. The core example in this article adds ~5MB to node_modules.
  • API learning curveAnnotation.Root, StateGraph, addConditionalEdges, channel naming rules (a node cannot share a name with a state attribute). These are not complex, but they are something to learn.
  • Indirection — the actual work still happens in plain async functions. The graph is a layer on top that routes state between them. For simple linear pipelines, the plain function chain is less code and easier to follow.

When to use it

The graph model shines when your workflow has:

  • Multiple branching paths (approve vs. reject, publish vs. archive)
  • Parallel execution (run three research agents at once)
  • Nested or looping subgraphs (a review loop inside a publishing workflow)
  • Complex state merging requirements

For a simple linear handoff — the kind most small content teams need — the plain async function chain from the TShandoff example is simpler, lighter, and just as reliable. The graph adds value when the control flow itself becomes complex enough to need its own representation.


8. Putting It All Together

Here is the checklist to build your own LangGraph workflow:

  1. Define your state typeWorkflowState with all the fields each node will read and write. Use Annotation.Root to register them as graph channels.
  2. Write node functions — each node is (state) => Partial<state>. It reads what it needs and returns only the fields it changes. The graph engine merges.
  3. Write the routing function — a pure function that reads the state and returns the next node name. This is the brain of the conditional logic.
  4. Wire the graphaddNode(), addEdge(), addConditionalEdges(). The graph is a declarative description of the workflow.
  5. Compile and invokeworkflow.compile() validates the graph, then app.invoke(initialState) runs it.
  6. Inspect the result — the final state contains every intermediate value. Log it, save it, debug it.

That is the entire pattern. The graph is not doing anything the plain async functions could not do — it is doing the same thing, but with explicit structure, state management and compile-time validation. Whether that is worth the dependency cost depends entirely on the complexity of your workflow.


Going Further

  • Add Promise.all parallelism — LangGraph has built-in parallel node execution. Run three research agents simultaneously for different keywords.
  • Human-in-the-loop — LangGraph supports interrupt() to pause the graph and wait for human input before continuing.
  • Subgraphs — wrap the review loop as a subgraph and reuse it across different content types.
  • Persist state — LangGraph has checkpointers that save state to a database, allowing the workflow to survive restarts.
  • Mix with the other examples — the MiniAgent class from the earlier TypeScript examples uses tools (calculator, web search, etc.). You can drop that class into any LangGraph node as a node function.

The full source is at https://github.com/juustesout/typescript-agent-example-langgraph in the TSLangGraph folder. Open graph.ts — it is one file, four nodes, five edges, and the exact same agent logic you already understand from the previous four examples.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top