Custom States and Multi-Node Workflows

2026-02-27

Today’s goals

  • Design custom TypedDict states for non-conversational workflows
  • Build multi-node graphs with specialized roles
  • Route with conditional edges based on state values
  • Use reducers to control how state updates are merged
  • Wrap workflows with simple command-line interfaces

Recall: MessagesState

In decks 05-07, we used MessagesState for conversational apps:

from langgraph.graph import StateGraph, MessagesState

def agent(state: MessagesState):
    return {"messages": [llm_with_tools.invoke(state["messages"])]}

graph = StateGraph(MessagesState)

MessagesState is a TypedDict with one field: messages (a list that accumulates).

When MessagesState works great

  • Chat applications
  • Tool-calling agents
  • Retrieval Q&A
  • Any workflow centered on conversation history

Pattern: user message → [tool calls if needed] → AI response

What if your app isn’t conversational?

Consider an arXiv paper analyzer:

  1. Fetch paper content from arXiv
  2. Extract metadata (title, authors, contributions)
  3. Look up publication info (tool: Semantic Scholar API)
  4. Generate summary with impact metrics and links

No chat history needed—just data flowing through processing stages.

Custom states for structured workflows

Choose the state structure that matches your data flow.

Part 1: arXiv Paper Analyzer

Example 1: Paper analyzer

Goal: Given an arXiv link, fetch the paper, look up its publication info, and generate a summary with impact metrics.

from typing_extensions import TypedDict

class PaperState(TypedDict):
    arxiv_url: str              # Input: arXiv link
    arxiv_id: str | None        # Extracted ID (e.g., "1706.03762")
    content: str | None         # Paper text
    metadata: dict | None       # title, authors, contributions
    impact: dict | None         # citations, venue, published DOI
    summary: str | None         # Final output

Each field serves a specific purpose in the pipeline.

The pipeline structure

Four nodes: fetch (tool), extract, look up impact (tool), summarize.

Node 1: Fetch paper (tool)

import requests
import fitz  # PyMuPDF
from langchain.tools import tool

@tool
def fetch_arxiv_paper(arxiv_id: str) -> str:
    """Fetch an arXiv paper by its ID (e.g., '1706.03762') and extract text."""
    pdf_url = f"https://arxiv.org/pdf/{arxiv_id}.pdf"
    response = requests.get(pdf_url)

    if response.status_code != 200:
        return f"Error: HTTP {response.status_code}"

    # Load PDF and extract text
    doc = fitz.open(stream=response.content, filetype="pdf")
    text = "\n".join(page.get_text(sort=True) for page in doc)
    doc.close()

    return text

Uses PyMuPDF (pip install pymupdf) to extract text. Note: equations/complex layouts may not extract cleanly.

Node 1: Using the fetch tool

from langchain_google_genai import ChatGoogleGenerativeAI
from langchain.messages import HumanMessage, ToolMessage
import json

llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0.1)

def fetch_content(state: PaperState) -> dict:
    """Let LLM parse URL and fetch paper content."""
    llm_with_tools = llm.bind_tools([fetch_arxiv_paper])
    messages = [HumanMessage(
        content=f"Extract the arXiv ID from this URL and fetch the paper: {state['arxiv_url']}"
    )]

    for attempt in range(3):  # Max 3 attempts
        response = llm_with_tools.invoke(messages)
        if not response.tool_calls:
            raise ValueError("LLM did not call the fetch tool")

        tool_call = response.tool_calls[0]
        arxiv_id = tool_call["args"]["arxiv_id"]

        try:
            result = fetch_arxiv_paper.invoke({"arxiv_id": arxiv_id})
        except Exception as e:
            result = f"Error: {e}"

        if result and not result.startswith("Error:"):
            return {"arxiv_id": arxiv_id, "content": result}

        # Tool failed—let LLM see error and retry
        messages.append(response)
        messages.append(ToolMessage(content=result or "No text extracted", tool_call_id=tool_call["id"]))

    raise ValueError(f"Failed to fetch paper after 3 attempts: {state['arxiv_url']}")

Why this pattern?

Why LLM-driven? URL formats vary. The LLM parses flexibly and retries on errors.

Why a manual loop instead of ToolNode/tools_condition?

  • Data pipeline with custom state—not a conversational agent
  • Only one node needs retries; the rest are simple
  • Messages stay local, not in pipeline state
  • Graph structure stays simple

Node 2: Extract metadata

from pydantic import BaseModel

class PaperMetadata(BaseModel):
    title: str
    authors: list[str]
    key_contributions: list[str] = []

def extract_metadata(state: PaperState) -> dict:
    """Use LLM to extract structured metadata from paper content."""
    structured_llm = llm.with_structured_output(PaperMetadata)

    prompt = f"""Extract metadata from this research paper.

Paper content:
{state['content']}"""

    metadata = structured_llm.invoke(prompt)
    return {"metadata": metadata.model_dump()}

with_structured_output() guarantees valid output matching the schema.

Node 3: Get paper impact (tool)

A second tool for external API lookup:

@tool
def get_paper_impact(arxiv_id: str) -> str:
    """Look up paper metadata and citations via Semantic Scholar."""
    url = f"https://api.semanticscholar.org/graph/v1/paper/arXiv:{arxiv_id}"
    params = {"fields": "title,authors,citationCount,venue,externalIds"}
    response = requests.get(url, params=params)

    if response.status_code == 200:  # HTTP 200 = success
        data = response.json()
        return json.dumps({
            "title": data.get("title"),
            "authors": [a["name"] for a in data.get("authors", [])],
            "citations": data.get("citationCount", 0),
            "venue": data.get("venue"),
            "doi": data.get("externalIds", {}).get("DOI")
        })
    return json.dumps({"error": "Paper not found"})

Node 3: Using the tool

def get_impact(state: PaperState) -> dict:
    """Look up publication info, overwrite metadata if available."""
    result = get_paper_impact.invoke({"arxiv_id": state["arxiv_id"]})
    data = json.loads(result)

    if "error" in data:  # Check for error key, not string in values
        return {"impact": {}}  # Keep Node 2's metadata

    # Overwrite title/authors with API data (more reliable), keep contributions
    updated = (state.get("metadata") or {}).copy()
    if data.get("title"):
        updated["title"] = data["title"]
    if data.get("authors"):
        updated["authors"] = data["authors"]

    return {
        "metadata": updated,
        "impact": {
            "citations": data.get("citations", 0),
            "venue": data.get("venue"),
            "doi": data.get("doi")
        }
    }

How state updates work

By default, returning a field replaces its value in state:

  • Node 2 returns {"metadata": {...}} → sets metadata
  • Node 3 returns {"metadata": {...}} → replaces metadata

That’s why we do state.get("metadata").copy() then modify—if we just returned new metadata, we’d lose the key_contributions from Node 2.

Key insight: Only include a field in your return dict if you want to change it. If Node 3 returns {"impact": {}} without "metadata", the existing metadata is preserved.

Node 4: Summarize

def summarize(state: PaperState) -> dict:
    """Create a summary using all gathered information."""
    arxiv_link = f"https://arxiv.org/abs/{state['arxiv_id']}"
    doi = state.get("impact", {}).get("doi")
    doi_link = f"https://doi.org/{doi}" if doi else None

    prompt = f"""Summarize this research paper using the output format below.

<paper_content>
{state['content']}
</paper_content>

<metadata>
{state['metadata']}
</metadata>

<impact>
Citations: {state.get('impact', {}).get('citations', 'Unknown')}
Venue: {state.get('impact', {}).get('venue', 'Unknown')}
</impact>

<links>
arXiv: {arxiv_link}
DOI: {doi_link}
</links>

<output_format>
# [Paper Title]

**Authors:** [comma-separated list]

## Summary
[2-3 paragraph summary of the paper's main findings and methodology]

## Key Contributions
[bulleted list]

## Impact
**Citations:** [count] | **Venue:** [venue]

## Links
[markdown links to arXiv and DOI if available]
</output_format>"""

    response = llm.invoke([HumanMessage(content=prompt)])
    return {"summary": response.content}

Building the graph

from langgraph.graph import StateGraph, START, END

def build_paper_analyzer():
    graph = StateGraph(PaperState)

    # Add nodes
    graph.add_node("fetch_content", fetch_content)
    graph.add_node("extract_metadata", extract_metadata)
    graph.add_node("get_impact", get_impact)
    graph.add_node("summarize", summarize)

    # Connect in sequence
    graph.add_edge(START, "fetch_content")
    graph.add_edge("fetch_content", "extract_metadata")
    graph.add_edge("extract_metadata", "get_impact")
    graph.add_edge("get_impact", "summarize")
    graph.add_edge("summarize", END)

    return graph.compile()

Running the pipeline

app = build_paper_analyzer()

result = app.invoke({
    "arxiv_url": "https://arxiv.org/abs/1706.03762",
    "arxiv_id": None,
    "content": None,
    "metadata": None,
    "impact": None,
    "summary": None
})

print(result["summary"])

No messages anywhere—just structured data flowing through.

Custom state patterns

Key principles:

  1. Include all fields your workflow needs
  2. Use typed fields (str, dict, list, None for optional)
  3. Return partial updates from nodes (only changed fields)
  4. Initialize properly when invoking
# Node returns only what it changes
def my_node(state: MyState) -> dict:
    return {"one_field": new_value}  # Other fields untouched

Part 2: Conditional Routing

Beyond sequential: branching workflows

Sometimes the next step depends on what happened in previous steps.

Example: Content moderation

  • Classify content as safe, questionable, or harmful
  • Route to different handlers based on classification

Example 2: Content moderation

from typing import Literal
from pydantic import BaseModel

class ModerationState(TypedDict):
    content: str              # Input content to moderate
    classification: str | None # "safe", "questionable", or "harmful"
    result: str | None        # Final decision/action taken

class ContentClassification(BaseModel):
    classification: Literal["safe", "questionable", "harmful"]

# Using same llm from Example 1
def classify_content(state: ModerationState) -> dict:
    """Classify content into safety categories."""
    structured_llm = llm.with_structured_output(ContentClassification)

    prompt = f"""Classify this content:
- safe: appropriate for all audiences
- questionable: needs human review
- harmful: violates content policies

Content: {state['content']}"""

    result = structured_llm.invoke(prompt)
    return {"classification": result.classification}

The routing function

def route_content(state: ModerationState) -> Literal["publish", "human_review", "reject"]:
    """Route based on classification result."""
    classification = state["classification"]

    if classification == "safe":
        return "publish"
    elif classification == "questionable":
        return "human_review"
    else:  # harmful
        return "reject"

The Literal return type documents the possible destinations clearly.

The moderation workflow

Different paths for different classifications.

Handler nodes

def publish(state: ModerationState) -> dict:
    """Approve and publish the content."""
    return {"result": "Content approved and published."}

def human_review(state: ModerationState) -> dict:
    """Flag for human review."""
    return {"result": "Content flagged for human review."}

def reject(state: ModerationState) -> dict:
    """Reject harmful content."""
    return {"result": "Content rejected due to policy violation."}

Each handler returns the appropriate result.

Building the moderation graph

def build_moderation_workflow():
    graph = StateGraph(ModerationState)

    graph.add_node("classify", classify_content)
    graph.add_node("publish", publish)
    graph.add_node("human_review", human_review)
    graph.add_node("reject", reject)

    graph.add_edge(START, "classify")

    # Conditional routing after classification
    graph.add_conditional_edges("classify", route_content)

    # All handlers lead to END
    graph.add_edge("publish", END)
    graph.add_edge("human_review", END)
    graph.add_edge("reject", END)

    return graph.compile()

Visualizing the graph

app = build_moderation_workflow()

# Save graph visualization as PNG
png_data = app.get_graph().draw_mermaid_png()
with open("graph.png", "wb") as f:  # wb = write binary
    f.write(png_data)

LangGraph uses the Literal return type from route_content to discover all possible paths—that’s how it knows to show edges to publish, human_review, and reject.

Visualization output

Dotted lines indicate conditional edges—the path taken depends on the routing function’s return value.

Running the moderation workflow

app = build_moderation_workflow()

result = app.invoke({
    "content": "Here's a helpful tutorial on Python programming.",
    "classification": None,
    "result": None
})
print(result["result"])  # "Content approved and published."

Part 3: Reducers

The problem: multiple nodes updating the same field

What if multiple nodes need to add to the same field?

Example: Multiple reviewers each provide feedback

def clarity_reviewer(state: ReviewState) -> dict:
    return {"feedback": ["Clarity: Introduction is clear."]}

def technical_reviewer(state: ReviewState) -> dict:
    return {"feedback": ["Technical: Methodology is sound."]}

Without special handling, the second reviewer overwrites the first!

Example 3: Multi-reviewer workflow

We want all feedback to accumulate, not overwrite.

Reducers control state merging

A reducer defines how updates are combined with existing state.

from typing import Annotated
import operator

class ReviewState(TypedDict):
    document: str
    feedback: Annotated[list[str], operator.add]
    final_summary: str | None

Here operator.add is the reducer—a function that takes (old, new) and returns the merged result.

What this tells LangGraph

Annotated[list[str], operator.add] means:

  • The field type is list[str]
  • When a node returns {"feedback": [...]}, call operator.add(existing, new)
  • Result: lists are concatenated, not replaced

Without a reducer: each update replaces the field. With operator.add: updates accumulate.

Clarity and technical reviewers

# Using same llm setup as before
def clarity_reviewer(state: ReviewState) -> dict:
    """Review for clarity and readability."""
    prompt = f"""Review this document for clarity.
Provide 1-2 sentences of feedback.

Document: {state['document']}"""

    response = llm.invoke([HumanMessage(content=prompt)])
    return {"feedback": [f"Clarity: {response.content}"]}

def technical_reviewer(state: ReviewState) -> dict:
    """Review for technical accuracy."""
    prompt = f"""Review this document for technical accuracy.
Provide 1-2 sentences of feedback.

Document: {state['document']}"""

    response = llm.invoke([HumanMessage(content=prompt)])
    return {"feedback": [f"Technical: {response.content}"]}

Style reviewer and synthesis

def style_reviewer(state: ReviewState) -> dict:
    """Review for writing style."""
    prompt = f"""Review this document for writing style.
Provide 1-2 sentences of feedback.

Document: {state['document']}"""

    response = llm.invoke([HumanMessage(content=prompt)])
    return {"feedback": [f"Style: {response.content}"]}

def synthesize(state: ReviewState) -> dict:
    """Combine all reviewer feedback into a summary."""
    all_feedback = "\n".join(state["feedback"])
    prompt = f"""Synthesize this feedback into actionable recommendations:

{all_feedback}"""

    response = llm.invoke([HumanMessage(content=prompt)])
    return {"final_summary": response.content}

Building the review workflow

def build_review_workflow():
    graph = StateGraph(ReviewState)

    graph.add_node("clarity", clarity_reviewer)
    graph.add_node("technical", technical_reviewer)
    graph.add_node("style", style_reviewer)
    graph.add_node("synthesize", synthesize)

    graph.add_edge(START, "clarity")
    graph.add_edge("clarity", "technical")
    graph.add_edge("technical", "style")
    graph.add_edge("style", "synthesize")
    graph.add_edge("synthesize", END)

    return graph.compile()

Running the review workflow

app = build_review_workflow()

result = app.invoke({
    "document": """This paper presents a novel approach to...""",
    "feedback": [],  # Start with empty list
    "final_summary": None
})

print("All feedback collected:")
for item in result["feedback"]:
    print(f"  - {item}")

print(f"\nSynthesis:\n{result['final_summary']}")

All three reviewers’ feedback is collected automatically!

You’ve already been using a reducer

MessagesState uses add_messages as its reducer:

from langgraph.graph.message import add_messages

# Simplified definition of MessagesState
class MessagesState(TypedDict):
    messages: Annotated[list, add_messages]

That’s why messages accumulate instead of being replaced.

Common reducer patterns

Pattern Use case
Annotated[list, operator.add] Collecting items from multiple nodes
Annotated[list, add_messages] Chat message history
Custom function Keeping max, merging dicts, etc.

Custom reducer example

Keep the highest confidence score (e.g., spam detection):

def keep_max(existing: float | None, new: float) -> float:
    """Keep the higher of two values."""
    if existing is None:
        return new
    return max(existing, new)

class AnalysisState(TypedDict):
    document: str
    confidence: Annotated[float | None, keep_max]

Now multiple analyzers can update confidence and the highest wins.

Part 4: Command-Line Interfaces

Why a CLI?

You’ve been using CLIs like python -m ai_in_loop.cli chat. Now let’s see how to build one:

  • Clear interface — users see available options with --help
  • Input validation — catches errors before running the workflow
  • Easy to automate — call from scripts, cron jobs, other programs

Anatomy of a CLI command

python analyze.py paper.txt --output summary.txt
       ─────────  ────────   ──────────────────
        script    argument    option with value
  • Arguments: required inputs, positional (order matters)
  • Options: optional settings, named with -- or -
  • Flags: options that are on/off (no value needed)

Typer: CLIs from type hints

Typer turns Python functions into CLI commands:

import typer

def analyze(input_file: str):
    """Analyze a document."""
    print(f"Processing: {input_file}")

if __name__ == "__main__":
    typer.run(analyze)
$ python analyze.py paper.txt
Processing: paper.txt

Function parameters become CLI arguments. Docstring becomes help text.

Arguments vs options in Typer

import typer

def analyze(
    input_file: str,                        # Required argument
    output: str | None = None               # Optional argument (has default)
):
    ...
python analyze.py paper.txt              # output is None
python analyze.py paper.txt summary.txt  # output is "summary.txt"
  • No default → required argument
  • Has default → optional argument

But what about --output style options?

Adding options with typer.Option

import typer

def analyze(
    arxiv_url: str,
    output: str | None = typer.Option(None, "--output", "-o", help="Output file")
):
    """Analyze an arXiv paper and generate a summary."""
    print(f"URL: {arxiv_url}")
    print(f"Output: {output}")

if __name__ == "__main__":
    typer.run(analyze)
python analyze.py https://arxiv.org/abs/1706.03762                 # output is None
python analyze.py https://arxiv.org/abs/1706.03762 -o summary.txt  # output is "summary.txt"

typer.Option converts a parameter into a --name style option.

How it looks to the user

$ python analyze.py "https://arxiv.org/abs/1706.03762" -o summary.txt
Summary written to summary.txt

$ python analyze.py --help
Usage: analyze.py [OPTIONS] ARXIV_URL

  Analyze an arXiv paper and generate a summary.

Arguments:
  ARXIV_URL  [required]

Options:
  -o, --output TEXT  Output file
  --help             Show this message and exit.

Wrapping a LangGraph workflow

import typer

def analyze(
    arxiv_url: str,
    output: str | None = typer.Option(None, "--output", "-o", help="Output file")
):
    """Analyze an arXiv paper and generate a summary."""
    # Run the LangGraph workflow from Example 1
    pipeline = build_paper_analyzer()
    result = pipeline.invoke({
        "arxiv_url": arxiv_url,
        "arxiv_id": None, "content": None,
        "metadata": None, "impact": None, "summary": None
    })

    if output:
        with open(output, "w") as f:
            f.write(result["summary"])
        print(f"Summary written to {output}")
    else:
        print(result["summary"])

if __name__ == "__main__":
    typer.run(analyze)

Using the CLI

# Print summary to console
python analyze.py "https://arxiv.org/abs/1706.03762"

# Save summary to file
python analyze.py "https://arxiv.org/abs/1706.03762" -o summary.txt

# Get help
python analyze.py --help

Typer handles argument parsing, validation, and generates --help automatically.

Boolean flags

Use bool with a False default to create on/off flags:

def analyze(
    arxiv_url: str,
    output: str | None = typer.Option(None, "--output", "-o"),
    verbose: bool = typer.Option(False, "--verbose", "-v",
                                  help="Show intermediate steps")
):
    if verbose:
        print(f"Fetching {arxiv_url}...")
    ...
python analyze.py "https://arxiv.org/abs/1706.03762" --verbose  # verbose is True
python analyze.py "https://arxiv.org/abs/1706.03762"            # verbose is False

Key takeaways

  • Custom TypedDict states for non-conversational workflows
  • Nodes return partial updates—LangGraph merges them
  • Conditional edges route based on state values
  • Reducers control how updates merge (accumulate vs replace)
  • Typer wraps workflows as command-line tools

When to use what

Scenario Approach
Chat or Q&A MessagesState
Structured pipeline Custom TypedDict
Sequential steps add_edge()
Branching logic add_conditional_edges()
Accumulating data Annotated[list, operator.add]

Resources