teachyou.ai academy
← All posts
LangGraph

LangGraph for Legal Document Review Agents

Pramod Dutta · Jun 19, 2026 · 16 min read

Legal document review is one of those workflows that looks like a perfect fit for a single well-crafted prompt right up until you actually try it. You paste a 40-page master services agreement into a chat window, ask the model to "flag anything risky," and get back a confident summary that misses the auto-renewal clause buried in section 14, invents a limitation of liability that is not there, and gives you no way to trace which passage produced which finding. The problem is not the model. The problem is that contract review is a multi-stage process with branching decisions, mandatory human checkpoints, and a hard requirement for auditability — and a single prompt gives you none of that structure. This is exactly the shape of problem LangGraph was built for. In this article we will design and build a LangGraph legal review agent step by step: a stateful graph that ingests a contract, classifies it, extracts and scores clauses, routes high-risk documents to a human lawyer, and records every decision it makes along the way.

Why Legal Document Review Is a Graph Problem, Not a Prompt Problem

Before writing any code, it is worth being precise about why the naive approach fails, because the failure modes dictate the architecture.

First, contracts are long and heterogeneous. A commercial lease, an NDA, and a SaaS subscription agreement need different review checklists. A single prompt either becomes a bloated union of every checklist — which degrades instruction-following — or it stays generic and misses domain-specific risks. The fix is classification followed by routing: figure out what kind of document you are holding, then run the review path built for that type. Routing between alternative paths based on intermediate results is a conditional edge, which is a graph primitive, not a prompting technique.

Second, review is inherently multi-pass. Extracting clauses, assessing each clause against a playbook, cross-referencing defined terms, and producing a summary memo are distinct cognitive tasks. When you cram them into one generation, errors in early implicit steps silently poison later ones, and you cannot retry one stage without redoing everything. As separate nodes, each stage can be prompted narrowly, validated independently, and retried in isolation.

Third — and this is the one that matters most in legal — a human must stay in the loop. No serious legal team will accept an agent that unilaterally approves contract language. The system needs first-class pause points where a lawyer reviews the agent's findings, edits them, and resumes execution. LangGraph's interrupt mechanism and checkpointer make this a native capability rather than a bolted-on hack: the graph literally suspends mid-execution, persists its state, and picks up exactly where it left off when the human responds, whether that is thirty seconds or three days later.

Finally, legal work demands an audit trail. When a lawyer asks "why did the agent flag this indemnification clause as high risk?", you need to reproduce the exact state that produced that judgment. Because LangGraph checkpoints state at every super-step, the audit trail falls out of the architecture for free.

LangGraph Concepts You Need Before Building

If you have used LangChain but not LangGraph, here is the minimal mental model. A LangGraph application is a state machine. You define a state schema — typically a TypedDict or Pydantic model — that describes everything the workflow knows at any moment. You define nodes, which are plain Python functions that receive the current state and return a partial update to it. You define edges, which say which node runs after which. Conditional edges call a routing function on the current state and pick the next node dynamically. The whole thing compiles into a runnable graph.

Three additional concepts do the heavy lifting for legal review:

  • Reducers. By default, a node's return value overwrites the corresponding state keys. If you annotate a key with a reducer such as operator.add, updates are merged instead — essential when parallel clause-analysis branches each contribute findings to a shared list.
  • Checkpointers. A checkpointer persists state after every step, keyed by a thread_id. This gives you resumability, time travel for debugging, and durable audit logs. In development you use InMemorySaver; in production you point the same interface at Postgres or SQLite.
  • Interrupts. Calling interrupt(payload) inside a node pauses the graph and surfaces the payload to your application layer. Execution resumes when you invoke the graph again with a Command(resume=...) carrying the human's response. This is the primitive that turns "AI drafts, human decides" from a slogan into control flow.

Everything else in this article is an application of those pieces to the specific shape of contract review.

Designing the State Schema for a Contract Review Agent

The state schema is the most consequential design decision you will make, because every node reads from and writes to it. For legal review, resist the temptation to model state as a chat message list. Messages are a fine transport for conversational agents, but a review pipeline is better served by explicit, typed fields that downstream nodes and your UI can rely on.

Here is a schema that has proven practical:

from typing import Annotated, Literal, Optional, TypedDict
import operator

class ClauseFinding(TypedDict):
    clause_type: str          # e.g. "limitation_of_liability"
    location: str             # section reference, e.g. "Section 12.3"
    verbatim_text: str        # exact quoted language
    risk_level: Literal["low", "medium", "high"]
    rationale: str            # why it was scored this way
    playbook_rule: str        # which policy rule triggered
    suggested_redline: Optional[str]

class ReviewState(TypedDict):
    document_text: str
    document_type: Optional[str]        # set by classifier node
    party_role: str                     # "customer" or "vendor" side
    findings: Annotated[list[ClauseFinding], operator.add]
    missing_clauses: list[str]          # expected but absent
    overall_risk: Optional[Literal["low", "medium", "high"]]
    human_verdict: Optional[str]        # "approved" / "revise" / notes
    summary_memo: Optional[str]
    revision_count: int

A few deliberate choices are embedded here. verbatim_text forces every finding to quote the actual contract language, which is your primary defense against hallucinated clauses — a finding that cannot be located in the source text can be rejected mechanically by a validation step. playbook_rule ties each risk score to a named policy, so the rationale is grounded in your firm's standards rather than the model's general opinions. party_role matters enormously: an unlimited indemnity is a threat if you represent the indemnifying party and a benefit if you represent the other side, and clause assessment prompts must know which chair you sit in. findings uses the operator.add reducer so that parallel analysis branches append rather than clobber each other. And revision_count exists to enforce a loop budget, which we will need when we build the revise-and-recheck cycle.

Building the Core Graph: Ingest, Classify, Extract, Assess

With state defined, the pipeline decomposes into narrow, testable nodes. The backbone looks like this:

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver

def classify_document(state: ReviewState):
    prompt = f"""Classify this legal document into exactly one type:
    nda, msa, saas_agreement, employment, lease, other.
    Return only the label.

    Document (first 4000 chars):
    {state['document_text'][:4000]}"""
    doc_type = llm.invoke(prompt).content.strip().lower()
    return {"document_type": doc_type}

def extract_clauses(state: ReviewState):
    checklist = PLAYBOOKS[state["document_type"]]["expected_clauses"]
    prompt = f"""You are reviewing a {state['document_type']} on behalf of
    the {state['party_role']}. For each clause type in this checklist,
    quote the exact contract language and its section reference, or state
    ABSENT if the document does not contain it.

    Checklist: {checklist}

    Document:
    {state['document_text']}"""
    extracted = llm.with_structured_output(ExtractionResult).invoke(prompt)
    missing = [c.clause_type for c in extracted.clauses if c.absent]
    return {"missing_clauses": missing}

def assess_risk(state: ReviewState):
    # one focused LLM call per extracted clause, scored against
    # the playbook rules for this document type and party role
    ...
    return {"findings": new_findings, "overall_risk": overall}

builder = StateGraph(ReviewState)
builder.add_node("classify", classify_document)
builder.add_node("extract", extract_clauses)
builder.add_node("assess", assess_risk)
builder.add_edge(START, "classify")
builder.add_edge("classify", "extract")
builder.add_edge("extract", "assess")

Two implementation details deserve emphasis. The extraction node works from an explicit checklist per document type — the playbook — rather than asking the model to freestyle "find the important clauses." Checklists convert an open-ended recall problem into a series of yes/no lookups, which models handle far more reliably, and the ABSENT option is what populates missing_clauses. In legal review, what a contract fails to say (no limitation of liability, no termination for convenience) is often more dangerous than what it says.

The assessment node fans out to one focused call per clause instead of scoring everything in a single mega-prompt. Small prompts with one job each are the difference between an agent that behaves and one that drifts. LangGraph's Send API lets you spawn these as genuinely parallel branches when latency matters, with the operator.add reducer safely merging their findings; for a first version, a simple loop inside the node is fine.

For long documents that exceed your comfort zone on context, chunk by section rather than by token count. Contracts have strong structural markers — numbered sections, headings, defined-terms blocks — and cutting across a section boundary is how you split a sentence like "notwithstanding the foregoing, liability shall be unlimited in cases of..." away from the cap it modifies. That is not a hypothetical: cross-references and carve-outs are precisely where contract meaning lives, and naive chunking severs them.

Conditional Edges: Routing Contracts by Risk

Not every document deserves the same depth of review, and encoding that triage into the graph is where conditional edges earn their keep.

def route_after_assessment(state: ReviewState) -> str:
    if state["overall_risk"] == "high" or state["missing_clauses"]:
        return "deep_review"
    if state["overall_risk"] == "medium":
        return "human_gate"
    return "draft_memo"        # low risk: straight to summary

builder.add_conditional_edges(
    "assess",
    route_after_assessment,
    {
        "deep_review": "deep_review",
        "human_gate": "human_gate",
        "draft_memo": "draft_memo",
    },
)

The routing function is plain Python operating on typed state, which means your triage policy is explicit, unit-testable, and reviewable by the legal team itself — not buried in a prompt where nobody can verify it. This matters organizationally as much as technically: when the general counsel asks "under what conditions does a contract skip human review?", you can point at eleven lines of code.

The deep_review node is where you escalate effort rather than just flagging and moving on. Practical escalations include re-running assessment with a stronger (more expensive) model reserved for high-stakes clauses, pulling the relevant playbook sections and precedent language into context for a grounded second opinion, and running a dedicated cross-reference pass that resolves defined terms — checking, for instance, that "Damages" as defined in section 1 does not quietly exclude the categories the indemnity in section 9 appears to cover. Tiering models this way also controls cost: a fast, cheap model classifies and extracts; the premium model is spent only where the routing function says the risk justifies it.

One more routing pattern worth building early: an escape_hatch branch for documents the classifier cannot confidently type, scanned PDFs whose text extraction came back garbled, or documents in an unexpected language. An agent that knows when to say "this needs a human from the start" is more trustworthy than one that soldiers on and produces plausible garbage.

Human-in-the-Loop: The interrupt That Keeps Lawyers in Charge

This is the section that determines whether your agent gets adopted or politely ignored. The human_gate node uses LangGraph's interrupt to suspend the graph and present findings for review:

from langgraph.types import interrupt, Command

def human_gate(state: ReviewState):
    verdict = interrupt({
        "task": "Review agent findings before memo generation",
        "document_type": state["document_type"],
        "overall_risk": state["overall_risk"],
        "findings": state["findings"],
        "missing_clauses": state["missing_clauses"],
    })
    # `verdict` is whatever the human sends back on resume
    return {
        "human_verdict": verdict["decision"],
        "findings": verdict.get("edited_findings", []),
    }

When execution reaches interrupt, the graph stops and the payload becomes available to your application — a web dashboard, a Slack message, an email to the assigned attorney. Crucially, the graph is not blocked in memory waiting on a socket; its state is checkpointed and the process can exit entirely. When the lawyer responds, you resume:

config = {"configurable": {"thread_id": "contract-2026-0142"}}
graph.invoke(
    Command(resume={"decision": "revise",
                    "notes": "Redline the liability cap to 12 months fees"}),
    config,
)

Design the payload as a decision, not a transcript. Lawyers are busy; showing them a wall of agent reasoning gets the interrupt rubber-stamped or abandoned. Show the risk level, the quoted clause, the one-line rationale, the suggested redline, and three buttons: approve, edit, reject. Every field they can edit flows back into state through the resume command, so the human's corrections become part of the record — and, if you are disciplined about logging them, a growing dataset of expert labels you can later use to tune prompts or fine-tune a scoring model.

Where you place gates is a policy decision the graph makes visible. A sensible default: always gate before any output leaves the system (memo sent, redline returned to counterparty), gate after assessment for medium risk and above, and let low-risk NDAs against your own standard template flow through with post-hoc spot-checking. Start with more gates than you think you need and remove them as trust accumulates; the reverse order is how tools get banned.

Checkpointing, Audit Trails, and Why Legal Teams Care

Attach a checkpointer at compile time and every super-step of every review is persisted:

checkpointer = InMemorySaver()   # Postgres-backed in production
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "contract-2026-0142"}}
result = graph.invoke(initial_state, config)

# Later: reconstruct exactly what the agent knew at each step
for snapshot in graph.get_state_history(config):
    print(snapshot.metadata["step"], snapshot.values.get("overall_risk"))

For most software this is a nice debugging feature. For legal software it is close to a requirement, and it is worth spelling out the three distinct jobs it does.

Durability first. Contract reviews interleave minutes of compute with days of human latency. The lawyer who receives the interrupt on Friday afternoon answers Tuesday morning. Because state lives in the checkpointer keyed by thread_id, nothing is lost across deploys, restarts, or that four-day gap — resumption is exact, not approximate.

Auditability second. get_state_history gives you a replayable record: the document text as ingested, the classification, each finding as it was added, the exact payload shown to the human, and the human's response. When a flagged clause is disputed months later, you reconstruct the agent's basis for the flag rather than shrugging at a black box. Pair the checkpointer with prompt versioning — store a playbook version identifier in state — so you can also answer "which version of our policy was this reviewed under?"

Time travel third. During development, update_state lets you rewind to any checkpoint, tweak a value, and re-run the downstream subgraph. Debugging "why did assessment mis-score this clause" becomes a five-minute exercise instead of a full pipeline re-run with print statements.

One operational caution: contracts are confidential by definition, and your checkpointer now holds full document text and findings at rest. Treat the checkpoint store with the same controls as your document management system — encryption at rest, role-based access, and a retention policy that legal ops signs off on. The thread_id should be a matter reference, not anything guessable.

Closing the Loop: Revision Cycles Without Infinite Loops

When the human verdict is "revise," the graph should act on the notes, not just record them. Add a revise node that regenerates suggested redlines incorporating the lawyer's instructions, then loops back to assessment so the revised language is re-scored under the same playbook:

def route_after_gate(state: ReviewState) -> str:
    if state["human_verdict"] == "approved":
        return "draft_memo"
    if state["revision_count"] >= 3:
        return "manual_takeover"     # stop looping, hand off entirely
    return "revise"

builder.add_conditional_edges("human_gate", route_after_gate,
    {"draft_memo": "draft_memo",
     "revise": "revise",
     "manual_takeover": "manual_takeover"})
builder.add_edge("revise", "assess")   # re-score revised language

The revision_count guard is not optional decoration. Cyclic graphs are LangGraph's superpower and its sharpest edge: an agent that keeps producing redlines the reviewer keeps rejecting will loop forever, burning tokens and goodwill in equal measure. Three strikes and the matter goes fully manual is a defensible default. LangGraph also enforces a recursion_limit at the graph level as a backstop, but you want your own domain-meaningful budget with a graceful exit path, not a raw GraphRecursionError surfacing to a paralegal.

The final draft_memo node has the easiest job in the pipeline precisely because everything upstream was structured: it converts the approved findings list into a review memo, grouping by risk level, quoting verbatim_text for every point, and citing playbook_rule for every recommendation. Generation from structured, human-approved state is dramatically more reliable than generation from raw documents — by this point the model is formatting decisions, not making them.

Common Failure Modes and How to Engineer Around Them

A few failure patterns show up in nearly every legal review agent, and each has a structural fix rather than a prompting fix.

  • Hallucinated clauses. The model reports a clause the document does not contain. Fix: require verbatim_text in every finding and add a cheap validation node that string-matches (with normalization for whitespace and quotes) each quote against the source. Findings that fail the match are dropped and the extraction retried — mechanically, without an LLM judge.
  • Confident scoring of ambiguous language. Models dislike saying "unclear." Fix: make ambiguity a first-class output — add an uncertain flag to ClauseFinding and route any uncertain finding to the human gate regardless of overall risk level. You want the agent's calibration surfaced in the graph, not smoothed over in prose.
  • Playbook drift. Review standards live in prompts that engineers edit and lawyers never see. Fix: externalize playbooks as versioned data (YAML works fine) that legal owns, loaded into prompts at runtime, with the version stamped into state for the audit trail.
  • Cross-reference blindness. A clause looks fine in isolation but is gutted by a definition or carve-out forty pages away. Fix: a dedicated defined-terms pass that builds a glossary from the definitions section and injects the relevant entries into each clause-assessment prompt. This is a retrieval problem inside a single document, and it is the single highest-leverage quality improvement after checklisting.
  • Silent regression after prompt changes. You improve the indemnity prompt and quietly break termination analysis. Fix: because nodes are pure functions of state, you can build a regression suite of contracts with known expected findings and run each node — or the whole graph — against it in CI, asserting on structured output rather than eyeballing prose.

None of these fixes are exotic. That is rather the point: once the workflow is a typed graph, quality engineering looks like normal software engineering.

From Prototype to Practice

The agent described here — classify, extract against a playbook, assess per clause, route by risk, gate on humans, checkpoint everything, loop with a budget — is a realistic first production architecture, not a toy. Start narrow: one document type (NDAs are the classic entry point, being short and standardized), one playbook, gates everywhere. Measure where the human reviewers change the agent's findings, because those diffs tell you exactly which prompts to fix and which gates you can eventually relax. Expand to a second document type only when the first one is boring.

What makes LangGraph the right substrate is that every hard requirement of the legal domain — branching review paths, mandatory human authority, durable interruption, replayable audit trails, bounded loops — maps onto a first-class framework primitive instead of a pile of custom orchestration code. You spend your effort on the playbooks and prompts, which is where the legal value lives, while the graph handles the control flow.

If you want to go deeper on the machinery used here — state schemas and reducers, conditional edges, the Send API for parallel fan-out, interrupts and the Command resume pattern, production checkpointing with Postgres, and debugging with time travel — the LangGraph Tutorial course on teachyou.ai walks through each primitive from first principles and builds up to complete multi-agent systems with human-in-the-loop, using hands-on projects rather than slides. It is the fastest route from "I can call an LLM" to "I can ship an agent a legal team will actually trust."