← Back to DevBytes

Building a Documentation Generator with LangGraph: Complete Guide

Building a Documentation Generator with LangGraph: Complete Guide

Documentation is often the most neglected part of the software development lifecycle. Developers write code faster than they write docs, and as codebases evolve, documentation drifts further from reality. What if you could build an automated system that reads your code, understands its structure, and generates accurate, maintainable documentation? In this guide, you'll learn how to build a documentation generator using LangGraph, the powerful graph-based framework for orchestrating language model workflows.

What Is LangGraph?

LangGraph is an extension of LangChain designed for building stateful, multi-actor applications with large language models. Unlike linear chains, LangGraph lets you define workflows as directed graphs where each node represents a computation step and edges define the flow of data between them. This graph-based approach is ideal for complex tasks like documentation generation, where you need branching logic, conditional routing, and iterative refinement.

A documentation generator built with LangGraph can analyze source files, extract meaningful structures, generate explanations, cross-reference APIs, and produce polished output in formats like Markdown or HTML. The graph structure makes it easy to add new capabilities, retry failed steps, and maintain state across long-running generation jobs.

Why Use LangGraph for Documentation Generation?

Traditional documentation tools like JSDoc, Sphinx, or Doxygen produce reference material, but they cannot explain why code exists or how components interact. LLM-powered generators bridge that gap by producing human-readable narratives. LangGraph specifically offers several advantages:

Project Setup

Start by creating a new project directory and installing the required dependencies. You'll need LangGraph, LangChain, and an LLM provider. This guide uses OpenAI, but you can swap in any supported provider.

mkdir doc-generator && cd doc-generator
python -m venv venv
source venv/bin/activate
pip install langgraph langchain langchain-openai
pip install python-dotenv pathlib

Create a .env file to store your API key securely:

OPENAI_API_KEY=sk-your-key-here

Defining the State Schema

Every LangGraph workflow revolves around a shared state object. For a documentation generator, the state needs to track the files being processed, extracted code structures, generated documentation chunks, and validation results. Define this schema using Python's TypedDict and Pydantic for type safety.

from typing import TypedDict, List, Dict, Optional, Annotated
from langgraph.graph import MessagesState
import operator

class FileEntry(TypedDict):
    path: str
    content: str
    language: str

class DocChunk(TypedDict):
    file_path: str
    summary: str
    functions: List[Dict[str, str]]
    classes: List[Dict[str, str]]
    markdown: str

class GeneratorState(TypedDict):
    files: List[FileEntry]
    chunks: Annotated[List[DocChunk], operator.add]
    current_index: int
    project_overview: str
    validation_errors: List[str]
    final_document: str

The Annotated type with operator.add tells LangGraph to merge list values from parallel branches instead of overwriting them. This is essential when multiple file-processing nodes run concurrently and each contributes documentation chunks.

Building the Graph Nodes

Each node in the graph is a function that takes the current state and returns a partial state update. Let's build the core nodes: file ingestion, code analysis, documentation generation, validation, and final assembly.

Node 1: File Ingestion

This node reads source files from a directory and populates the state with their contents. It also detects the programming language based on file extension.

import os
from pathlib import Path

EXTENSION_MAP = {
    ".py": "python",
    ".js": "javascript",
    ".ts": "typescript",
    ".java": "java",
    ".go": "go",
    ".rs": "rust",
}

def ingest_files(state: GeneratorState) -> dict:
    source_dir = Path("./src")
    files = []
    for root, _, filenames in os.walk(source_dir):
        for fname in filenames:
            ext = Path(fname).suffix
            if ext in EXTENSION_MAP:
                full_path = Path(root) / fname
                content = full_path.read_text(encoding="utf-8")
                files.append({
                    "path": str(full_path),
                    "content": content,
                    "language": EXTENSION_MAP[ext],
                })
    print(f"Ingested {len(files)} files")
    return {"files": files, "current_index": 0, "chunks": []}

Node 2: Code Analysis

The analysis node uses an LLM to extract the structure of a single file: its functions, classes, and a high-level summary. This structured extraction becomes the foundation for the documentation that follows.

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage

llm = ChatOpenAI(model="gpt-4o", temperature=0.2)

ANALYSIS_PROMPT = """You are a code analysis expert. Analyze the following {language} file
and extract its structure. Return a JSON object with:
- summary: one paragraph describing what the file does
- functions: list of {{name, signature, description}}
- classes: list of {{name, methods, description}}

File path: {path}
File content:
{content}
"""

def analyze_file(state: GeneratorState) -> dict:
    idx = state["current_index"]
    if idx >= len(state["files"]):
        return {}

    file_entry = state["files"][idx]
    prompt = ANALYSIS_PROMPT.format(
        language=file_entry["language"],
        path=file_entry["path"],
        content=file_entry["content"][:8000],
    )

    response = llm.invoke([
        SystemMessage(content="You output only valid JSON."),
        HumanMessage(content=prompt),
    ])

    import json
    try:
        analysis = json.loads(response.content)
    except json.JSONDecodeError:
        analysis = {
            "summary": "Failed to parse analysis output.",
            "functions": [],
            "classes": [],
        }

    chunk = {
        "file_path": file_entry["path"],
        "summary": analysis.get("summary", ""),
        "functions": analysis.get("functions", []),
        "classes": analysis.get("classes", []),
        "markdown": "",
    }

    return {"chunks": [chunk]}

Node 3: Documentation Generation

Once the structure is extracted, a second LLM call transforms that structure into polished Markdown documentation. Separating analysis from generation lets you use different prompts and even different models for each step.

DOC_PROMPT = """Convert the following code analysis into clean Markdown documentation.
Include a heading with the file name, a summary section, and subsections for
functions and classes. Use code blocks for signatures. Keep explanations concise
but informative.

File: {file_path}
Summary: {summary}
Functions: {functions}
Classes: {classes}
"""

def generate_docs(state: GeneratorState) -> dict:
    idx = state["current_index"]
    if idx >= len(state["chunks"]):
        return {}

    chunk = state["chunks"][idx]
    prompt = DOC_PROMPT.format(
        file_path=chunk["file_path"],
        summary=chunk["summary"],
        functions=chunk["functions"],
        classes=chunk["classes"],
    )

    response = llm.invoke([HumanMessage(content=prompt)])
    updated_chunk = dict(chunk)
    updated_chunk["markdown"] = response.content

    return {"chunks": [updated_chunk]}

Node 4: Validation

Validation ensures the generated documentation meets quality standards. This node checks for empty sections, missing function descriptions, and overly short summaries. Failed checks trigger a retry loop back to the generation node.

def validate_docs(state: GeneratorState) -> dict:
    idx = state["current_index"]
    if idx >= len(state["chunks"]):
        return {"validation_errors": []}

    chunk = state["chunks"][idx]
    errors = []

    if len(chunk["summary"].strip()) < 20:
        errors.append(f"Summary too short for {chunk['file_path']}")

    if not chunk["markdown"].strip():
        errors.append(f"Empty markdown for {chunk['file_path']}")

    for func in chunk["functions"]:
        if not func.get("description", "").strip():
            errors.append(f"Missing description for function {func.get('name')}")

    return {"validation_errors": errors}

Node 5: Assembly

The final node combines all validated chunks into a single cohesive document, adds a project overview, and writes the result to disk.

def assemble_document(state: GeneratorState) -> dict:
    overview_prompt = """Write a brief project overview based on these file summaries:
    {summaries}
    """
    summaries = "\n".join(
        f"- {c['file_path']}: {c['summary']}" for c in state["chunks"]
    )
    overview = llm.invoke([
        HumanMessage(content=overview_prompt.format(summaries=summaries))
    ]).content

    sections = [f"# Project Documentation\n\n{overview}\n"]
    for chunk in state["chunks"]:
        sections.append(chunk["markdown"])

    final_doc = "\n\n---\n\n".join(sections)
    Path("./DOCUMENTATION.md").write_text(final_doc, encoding="utf-8")

    return {"project_overview": overview, "final_document": final_doc}

Wiring the Graph Together

Now that all nodes exist, connect them using LangGraph's StateGraph API. The graph needs conditional edges to handle the iteration loop: after validation, either retry generation or advance to the next file.

from langgraph.graph import StateGraph, START, END

def should_retry_or_advance(state: GeneratorState) -> str:
    if state["validation_errors"]:
        if state["current_index"] < len(state["files"]):
            return "generate"
        return "assemble"
    next_index = state["current_index"] + 1
    if next_index < len(state["files"]):
        return "next_file"
    return "assemble"

def advance_index(state: GeneratorState) -> dict:
    return {"current_index": state["current_index"] + 1, "validation_errors": []}

builder = StateGraph(GeneratorState)

builder.add_node("ingest", ingest_files)
builder.add_node("analyze", analyze_file)
builder.add_node("generate", generate_docs)
builder.add_node("validate", validate_docs)
builder.add_node("advance", advance_index)
builder.add_node("assemble", assemble_document)

builder.add_edge(START, "ingest")
builder.add_edge("ingest", "analyze")
builder.add_edge("analyze", "generate")
builder.add_edge("generate", "validate")

builder.add_conditional_edges(
    "validate",
    should_retry_or_advance,
    {
        "generate": "generate",
        "next_file": "advance",
        "assemble": "assemble",
    },
)

builder.add_edge("advance", "analyze")
builder.add_edge("assemble", END)

graph = builder.compile()

Running the Generator

With the graph compiled, invoke it with an empty initial state. LangGraph handles the rest, routing through nodes according to the edges you defined.

from dotenv import load_dotenv

load_dotenv()

initial_state = {
    "files": [],
    "chunks": [],
    "current_index": 0,
    "project_overview": "",
    "validation_errors": [],
    "final_document": "",
}

result = graph.invoke(initial_state)
print(f"Generated documentation with {len(result['chunks'])} sections.")
print(f"Output written to DOCUMENTATION.md")

Adding Parallel Processing

For large codebases, processing files sequentially becomes a bottleneck. LangGraph supports a Send API that fans out work across multiple instances of a node. Here's how to modify the graph to process all files in parallel.

from langgraph.constants import Send

def fan_out_files(state: GeneratorState) -> List[Send]:
    return [
        Send("analyze", {
            "files": [f],
            "current_index": 0,
            "chunks": [],
        })
        for f in state["files"]
    ]

parallel_builder = StateGraph(GeneratorState)
parallel_builder.add_node("ingest", ingest_files)
parallel_builder.add_node("analyze", analyze_file)
parallel_builder.add_node("generate", generate_docs)
parallel_builder.add_node("assemble", assemble_document)

parallel_builder.add_edge(START, "ingest")
parallel_builder.add_conditional_edges("ingest", fan_out_files)
parallel_builder.add_edge("analyze", "generate")
parallel_builder.add_edge("generate", "assemble")
parallel_builder.add_edge("assemble", END)

parallel_graph = parallel_builder.compile()

Because the chunks field uses operator.add as its reducer, results from parallel branches merge automatically into a single list without conflicts.

Best Practices

Building a production-grade documentation generator requires more than wiring nodes together. Follow these practices to ensure reliability and quality:

Extending the Generator

The graph architecture makes it straightforward to add new capabilities. Some valuable extensions include:

Each extension is simply a new node and a few edges. You never need to rewrite existing logic because the graph composes naturally.

Conclusion

Building a documentation generator with LangGraph gives you a flexible, stateful, and extensible system that turns raw source code into meaningful documentation. By modeling the workflow as a graph, you gain fine-grained control over routing, retries, parallelism, and human review. The generator you built in this guide can serve as a foundation for a production tool: add caching for performance, structured outputs for reliability, and human checkpoints for quality assurance. As your codebase grows, the graph grows with it, accommodating new analysis steps and output formats without architectural rewrites. Documentation no longer has to be an afterthought — with LangGraph, it becomes an automated, integral part of your development pipeline.

— Ad —

Google AdSense will appear here after approval

← Back to all articles