Build a Local LLM Orchestrator

Build a local LangGraph workflow that plans, reviews, and revises drafts.

Introduction

30 Second Summary

A first draft can sound polished while hiding weak assumptions. Without a visible review, you cannot see whether the response improved before it reached you.

In this project, you will build a terminal-based draft-and-review orchestrator using Python with LangGraph over Ollama. You will watch every stage update the workflow before a reviewed final response appears.

What You'll Build

Your terminal turns one brief into a visible sequence of named stages before presenting a reviewed final draft.

By the end of this project, you'll have:

  • A local inference workflow that turns your brief into a final draft with qwen3:4b on your own computer.
  • A live orchestration trace that shows plan, draft, review, and revise update shared graph state under separate node names.
  • A bounded review loop that uses conditional routing to force one visible revision before completion.
  • Secret Mission: Make the model selectable at runtime so the unchanged graph can run with llama3.2:3b.

Are there any prerequisites?

You'll need basic Python skills plus a Linux computer with at least 16 GB of RAM. After the initial internet downloads, CPU-only execution is supported.

Before We Start

Before the hands-on work begins, lock in what you are building and why it matters. You are creating a visible local workflow that turns a brief into a plan, draft, review, revision, and final result.

Prepare the Local AI Stack

A local workflow only works when every layer can respond on your machine. One missing runtime or package would stop the orchestration before you could see any workflow decisions.

You will verify Python 3.11 or newer. You will keep the project packages inside a dedicated virtual environment in Visual Studio Code.

Next, you will install LangGraph and langchain-ollama. Finally, you will start Ollama and prove that Qwen3 4B answers locally.

In this step, get ready to:
  • Verify the required Python version inside a project workspace.
  • Install the pinned Python dependencies in a virtual environment.
  • Run Qwen3 4B through a local Ollama service.
Create the isolated Python workspace

The project requires Python 3.11 or newer. Checking the installed version first prevents package compatibility problems later.

  • Check your installed Python version by running this command:
python3 --version

What should I see?

The terminal prints a Python version number. A result starting with 3.11 or a higher number meets this project's requirement.

✔️ I see the required Python version

Your Python runtime is ready for the pinned project packages.

  • Continue to the Visual Studio Code workspace instructions below.

ⓧ I see an older Python version

The installed interpreter is older than this project requires. Upgrade it before creating the virtual environment.

  • Use your Linux distribution's software manager to install Python 3.11 or newer.
  • Check the upgraded version by running this command:
python3 --version

What confirms the upgrade?

The output must start with 3.11 or a higher version number before you continue.

ⓧ The command is not found

Your shell cannot locate Python 3. Install a supported interpreter before continuing.

  • Use your Linux distribution's software manager to install Python 3.11 or newer.
  • Check the new installation by running this command:
python3 --version

What confirms the installation?

The terminal must print a version starting with 3.11 or a higher number.

Your Python version is compatible. The next actions place every project file in one folder on your Desktop.

  • Open your Linux application search.
  • Type Visual Studio Code into the search field.
  • Select Visual Studio Code from the results.

You will see the Visual Studio Code window. You can now create the workspace that holds this project.

  • Select File from the top menu.
  • Select Open Folder....
  • Choose Desktop as the folder location.
  • Select New Folder.
  • Enter local-draft-review as the folder name.
  • Select the new local-draft-review folder.
  • Click Select Folder.

The Explorer sidebar now shows local-draft-review as your workspace folder.

  • Select Yes, I trust the authors if the workspace trust dialog appears.
  • Select View from the top menu.
  • Select Terminal.

The integrated terminal starts inside local-draft-review. A project-specific environment keeps the dependencies away from other Python projects on your computer.

  • Create the .venv virtual environment by running this command:
python3 -m venv .venv

What does this command create?

Python creates a .venv directory inside local-draft-review. This directory holds the isolated interpreter files and installed packages.

  • Activate the virtual environment by running this command:
source .venv/bin/activate

What does activation change?

Activation points this terminal at the interpreter inside .venv. Your shell prompt often shows the environment name after activation.

  • Confirm the active interpreter still meets the requirement by running this command:
python3 --version

What confirms the environment is ready?

The terminal prints Python 3.11 or newer. The active terminal is now ready to receive this project's packages.

Virtual environment not activating?

  • Confirm that .venv appears under local-draft-review in the Explorer sidebar.
  • Confirm that the integrated terminal is using a Bash-compatible Linux shell.
  • Still stuck? Help me troubleshoot my Python virtual environment.
Pin and install the Python dependencies

Pinned dependencies make the project repeatable. Your requirements.txt file records the exact library versions used by the finished orchestrator.

  • Select New File... in the Explorer sidebar.
  • Enter requirements.txt as the file name.
  • Press Enter.
  • Add the pinned dependency declarations by pasting this code:
langgraph==1.2.11
langchain-ollama==1.1.0

What do these dependencies provide?

  • The langgraph package provides the graph state and routing APIs used by the orchestrator.
  • The langchain-ollama package provides the adapter that lets Python send prompts to Ollama.
  • The version pins keep every learner on 1.2.11 and 1.1.0.
  • Save requirements.txt.

✔️ Awesome, I've got everything!

Your dependency file now contains both pinned packages. Keep requirements.txt open for the comparison below if needed.

ⓧ I'd like to double check the full code

  • Compare your complete requirements.txt file with this reference:
langgraph==1.2.11
langchain-ollama==1.1.0
  • Install the pinned dependencies into the active virtual environment by running this command:
python3 -m pip install -r requirements.txt

What does this installation do?

Python reads each declaration from requirements.txt. It installs the requested packages inside the active .venv environment.

The terminal downloads the packages before returning to the shell prompt. A completed installation means the dependency layer is present.

Dependency installation failing?

  • Confirm that your terminal still shows the virtual environment as active.
  • Confirm that requirements.txt contains both lines from the reference tab.
  • Still stuck? Help me troubleshoot the pinned package installation.

An import check proves that Python can load both installed libraries from this environment.

  • Check both imports by running this command:
python3 -c "import langgraph, langchain_ollama"

What does this import check prove?

The -c option asks Python to execute the short import statement directly. A return to the shell prompt without an import error confirms that both packages are available.

Seeing an import error?

  • Reactivate the virtual environment using the activation command from the previous substep.
  • Repeat the dependency installation from inside local-draft-review.
  • Still stuck? Help me fix the Python import check.
Install and test the local model runtime

Ollama serves models to applications running on your computer. This gives the project a local inference layer that Python can call without a cloud API.

The installer may ask for your Linux administrator password. The terminal keeps the password hidden while you type.

  • Install Ollama on Linux by running this command:
curl -fsSL https://ollama.com/install.sh | sh

What does this installer do?

The curl command retrieves Ollama's official Linux installer. The pipe passes that installer to sh for execution.

Ollama installation failing?

  • Confirm that your Linux machine has an active internet connection.
  • Confirm that your account can approve administrator-level installation changes.
  • Still stuck? Help me troubleshoot the Ollama Linux installation.

Ollama needs a running local service before it can load a model. Choose the route that matches how your Linux installation manages background services.

Use the system service

Use this route when your Linux distribution uses systemd. The service continues running after the command returns.

  • Start the Ollama system service by running this command:
sudo systemctl start ollama

What does this command do?

Systemd starts Ollama as a background service. Your current terminal becomes available for the model test.

Use a separate terminal

Use this route when the system service is unavailable. The server command keeps its terminal occupied while Ollama is running.

  • Select Terminal from the Visual Studio Code menu.
  • Select New Terminal.
  • Start the Ollama server in the new terminal by running this command:
ollama serve

What should I see?

The terminal begins showing Ollama server logs. Keep this terminal running while you use the original project terminal for the remaining commands.

  • Verify the Ollama installation from an available terminal by running this command:
ollama -v

What confirms Ollama is installed?

The terminal prints the installed Ollama version. This confirms that your shell can find the local runtime.

Ollama command unavailable?

  • Close the current terminal after the installation finishes.
  • Open a new Visual Studio Code terminal so the shell reloads its command path.
  • Still stuck? Help me find the installed Ollama command.

The first model launch downloads a 2.5 GB artifact before opening the chat. This is the longest wait in the setup because the full model stays on your computer.

Before you start this check, do you expect the response to come from your local model or an online service?

  • Download Qwen3 4B and open its interactive chat by running this command:
ollama run qwen3:4b

What does this command do?

Ollama downloads qwen3:4b when the model is missing. It then opens a local interactive session with that model.

  • Enter Reply with LOCAL READY at the model prompt.

You should see a model response containing LOCAL READY. This proves that Qwen is responding through your local Ollama runtime.

  • Enter /bye to leave the Ollama chat.

That is your local AI layer working. Python is isolated, the graph libraries import successfully, Ollama is running, and Qwen answers without a cloud service.

Your local stack is ready. Next, you will connect Python to the model with one direct call so you can see what orchestration still needs to add.

Test a Direct Model Call

Your local AI stack is ready. The next question is whether Python can reach the qwen3:4b model through the running Ollama service.

A direct call gives you the smallest useful connection test. Its single result also keeps every workflow decision hidden.

In this step, get ready to:
  • Create a Python baseline with the local model configuration.
  • Invoke qwen3:4b once with a bounded response.
  • Inspect the one-call output for hidden workflow decisions.
Configure the direct client

A model adapter gives Python a consistent way to call a model. ChatOllama connects your script to the model already available through Ollama.

  • Click New File in the Explorer sidebar in Visual Studio Code.
  • Enter baseline.py as the filename.
  • Add the baseline configuration by pasting this code:
from langchain_ollama import ChatOllama

MODEL = "qwen3:4b"
DEFAULT_TASK = (
    "Write a launch plan for a local AI study group. "
    "Include the audience, three actions, and one risk."
)

What Does This Configuration Do?

  • The ChatOllama import provides the adapter used to contact Ollama.
  • The MODEL constant keeps the local model identifier in one place.
  • The DEFAULT_TASK constant supplies a complete brief when you submit an empty input.
  • Save baseline.py.
  • Confirm the Explorer sidebar lists baseline.py beside requirements.txt.

File or Import Not Looking Right?

  • Check that baseline.py sits in the same project folder as requirements.txt.
  • Compare the import spelling with langchain_ollama.
  • Still stuck? Help me fix the baseline file or ChatOllama import.
Add the one-call program

The main() function collects one brief. It sends that brief through one model invocation before printing the returned content.

  • Place your cursor below the DEFAULT_TASK block in baseline.py.
  • Complete the direct-call program by pasting this code:
def main() -> None:
    task = input("Brief: ").strip() or DEFAULT_TASK
    model = ChatOllama(
        model=MODEL,
        temperature=0.2,
        num_predict=300,
        reasoning=False,
    )
    response = model.invoke(
        "Answer the following brief in no more than 180 words.\n\n"
        f"Brief: {task}"
    )

    print("\nMODEL RESPONSE\n")
    print(response.content)


if __name__ == "__main__":
    main()

How Does the Direct Call Work?

  • The task value uses what you enter at Brief:. Empty input selects DEFAULT_TASK.
  • The temperature setting uses 0.2 to favor steadier output.
  • The num_predict setting limits generation to 300 predicted tokens.
  • The reasoning=False setting disables reasoning mode for this response.
  • The model.invoke() call sends exactly one prompt to the local model.
  • The response.content value is the only model result printed.
  • Save baseline.py.
  • Confirm the final line in the editor calls main().

Code Not Lining Up?

  • Check that every line inside main() uses four spaces of indentation.
  • Confirm the if __name__ == "__main__": guard starts at the left edge.
  • Still stuck? Help me compare my baseline.py structure with the working version.

✔️ Awesome, I've got everything!

Your saved baseline.py now matches the direct-call baseline.

ⓧ I'd like to double check the full code

  • Compare your complete baseline.py file with this reference:
from langchain_ollama import ChatOllama

MODEL = "qwen3:4b"
DEFAULT_TASK = (
    "Write a launch plan for a local AI study group. "
    "Include the audience, three actions, and one risk."
)


def main() -> None:
    task = input("Brief: ").strip() or DEFAULT_TASK
    model = ChatOllama(
        model=MODEL,
        temperature=0.2,
        num_predict=300,
        reasoning=False,
    )
    response = model.invoke(
        "Answer the following brief in no more than 180 words.\n\n"
        f"Brief: {task}"
    )

    print("\nMODEL RESPONSE\n")
    print(response.content)


if __name__ == "__main__":
    main()
Run the baseline and inspect the limit

Before you run this, do you expect one model call to reveal how the answer was planned or checked?

  • Start the direct model call by running this command:
python baseline.py

What Does This Command Do?

This runs baseline.py with Python from your activated virtual environment. The script waits at Brief: for one task.

  • Submit the default brief by pressing Enter without typing anything.

You will see the MODEL RESPONSE heading followed by one locally generated answer. No named intermediate updates appear.

That is the key connection proven. Python can reach your local Qwen model through Ollama.

No Local Response?

  • Confirm the Ollama service from earlier is still running.
  • Return to the terminal with the activated .venv before running the script again.
  • Still stuck? Help me troubleshoot the direct local model call.
  • Scan the terminal for a named planning stage.
  • Look for a separate draft update.
  • Look for a separate review update.
  • Check for a shared state update.
  • Check for a routing decision.
  • Check for a controlled retry.

What Did the Baseline Leave Hidden?

The terminal prints no named plan or draft. It exposes only the final response.

There is no shared state update. There is no separate review.

No routing decision appears. The script has no controlled retry.

Your baseline now works. Next, you will replace its hidden process with a graph that shows planning before drafting.

Build the Planning and Drafting Graph

Your one-call baseline now reaches the local Qwen model through Ollama. Its final response exposes no named workflow stages.

A LangGraph StateGraph makes the hidden process visible. In this step, shared state moves from a planning node into a drafting node.

In this step, get ready to:
  • Define the typed state shared across the workflow.
  • Build separate planning and drafting nodes.
  • Stream each node update in the terminal.
Define shared workflow state

Each graph node needs a predictable state shape. WorkflowState uses TypedDict to define the values that can move through the graph.

  • In the VS Code file sidebar, create orchestrator.py beside baseline.py.
  • Paste this shared state foundation into orchestrator.py:
from typing import TypedDict

from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph

MODEL = "qwen3:4b"
MAX_REVISIONS = 1
DEFAULT_TASK = (
    "Write a launch plan for a local AI study group. "
    "Include the audience, three actions, and one risk."
)


class WorkflowState(TypedDict):
    task: str
    plan: str
    draft: str
    review: str
    status: str
    revision_count: int

What does this foundation define?

  • The imports provide the local model adapter plus the graph building blocks.
  • MODEL keeps the graph connected to qwen3:4b.
  • MAX_REVISIONS sets the workflow's revision limit to 1.
  • WorkflowState gives every node the same six fields.
  • Save orchestrator.py.

You should see task, plan, draft, review, status, plus revision_count inside WorkflowState.

Seeing import warnings?

  • Confirm the VS Code terminal still shows your activated .venv.
  • Compare each import with the code block above.
  • Still stuck? Help me diagnose the imports in orchestrator.py.
Build the planner and drafter

A graph node reads the current state. It returns only the fields that changed.

  • Add the model setup plus the planning node below WorkflowState in orchestrator.py:


def build_graph():
    model = ChatOllama(
        model=MODEL,
        temperature=0.2,
        num_predict=300,
        reasoning=False,
    )

    def ask(prompt: str) -> str:
        response = model.invoke(prompt)
        if isinstance(response.content, str):
            return response.content.strip()
        return str(response.content).strip()

    def plan_node(state: WorkflowState) -> dict[str, str]:
        plan = ask(
            "Act as a planner. Create exactly three numbered checkpoints "
            "for the brief below. Keep the plan under 90 words.\n\n"
            f"Brief: {state['task']}"
        )
        return {"plan": plan, "status": "planned"}

How does the planning node work?

  • ChatOllama creates one model adapter for the entire graph.
  • ask() sends a role-specific prompt to the local model.
  • plan_node() reads the brief from state['task'].
  • The returned dictionary updates only plan plus status.
  • Save orchestrator.py.

The planner now has one job. It turns the brief into exactly three checkpoints.

Does the planner look misaligned?

  • Keep ask() indented inside build_graph().
  • Keep plan_node() aligned with ask().
  • Need another pair of eyes? Help me check the indentation of my planning node.
  • Add the drafting node plus the linear graph directly below plan_node():

    def draft_node(state: WorkflowState) -> dict[str, str]:
        draft = ask(
            "Act as a concise writer. Use the plan to answer the brief in "
            "no more than 180 words. Return only the draft.\n\n"
            f"Brief: {state['task']}\n\n"
            f"Plan: {state['plan']}"
        )
        return {"draft": draft, "status": "drafted"}

    builder = StateGraph(WorkflowState)
    builder.add_node("plan", plan_node)
    builder.add_node("draft", draft_node)
    builder.add_edge(START, "plan")
    builder.add_edge("plan", "draft")
    builder.add_edge("draft", END)

    return builder.compile()

How does the graph move?

  • draft_node() reads the original task plus the generated plan.
  • StateGraph registers plan plus draft as separate nodes.
  • START sends state into the planner.
  • END stops the current graph after the first draft.
  • Save orchestrator.py.

Your graph now has a visible route from START to plan. The updated state then moves through draft to END.

Is the graph structure unclear?

  • Keep every builder line inside build_graph().
  • Check that each node name matches its corresponding function.
  • Want help tracing the route? Help me inspect my linear StateGraph.
Stream each node update

Streaming exposes each partial state update as the graph runs. The terminal can label the node responsible for every change.

  • Add the update display helper below build_graph():


def display_update(node_name: str, update: dict) -> None:
    print(f"\n[{node_name.upper()}]")
    for key in ("plan", "draft", "review", "status", "revision_count"):
        if key in update:
            print(f"{key.upper()}: {update[key]}")

What does the display helper show?

display_update() prints the current node name in uppercase. It then prints only the fields returned by that node.

  • Add the main execution function below display_update():


def main() -> None:
    task = input("Brief: ").strip() or DEFAULT_TASK
    graph = build_graph()

    initial_state: WorkflowState = {
        "task": task,
        "plan": "",
        "draft": "",
        "review": "",
        "status": "new",
        "revision_count": 0,
    }
    final_state = dict(initial_state)

    for chunk in graph.stream(
        initial_state,
        stream_mode="updates",
        version="v2",
    ):
        if chunk["type"] != "updates":
            continue
        for node_name, update in chunk["data"].items():
            final_state.update(update)
            display_update(node_name, update)

    print("\nFINAL DRAFT\n")
    print(final_state["draft"])

How does streaming expose the graph?

  • initial_state provides a value for every field in WorkflowState.
  • stream_mode="updates" requests changed state fields after each node.
  • version="v2" provides the event type plus node update data.
  • final_state accumulates each update before printing the completed draft.
  • Add the final execution guard at the bottom of orchestrator.py:


if __name__ == "__main__":
    main()

Why does the execution guard matter?

The guard calls main() when you run orchestrator.py directly. Importing the file elsewhere leaves the workflow dormant.

  • Save orchestrator.py.

✔️ Awesome, I've got everything!

Your orchestrator.py file now contains shared state plus a compiled two-node graph.

ⓧ I'd like to double check the full code

Compare your saved orchestrator.py with this complete version.

from typing import TypedDict

from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph

MODEL = "qwen3:4b"
MAX_REVISIONS = 1
DEFAULT_TASK = (
    "Write a launch plan for a local AI study group. "
    "Include the audience, three actions, and one risk."
)


class WorkflowState(TypedDict):
    task: str
    plan: str
    draft: str
    review: str
    status: str
    revision_count: int


def build_graph():
    model = ChatOllama(
        model=MODEL,
        temperature=0.2,
        num_predict=300,
        reasoning=False,
    )

    def ask(prompt: str) -> str:
        response = model.invoke(prompt)
        if isinstance(response.content, str):
            return response.content.strip()
        return str(response.content).strip()

    def plan_node(state: WorkflowState) -> dict[str, str]:
        plan = ask(
            "Act as a planner. Create exactly three numbered checkpoints "
            "for the brief below. Keep the plan under 90 words.\n\n"
            f"Brief: {state['task']}"
        )
        return {"plan": plan, "status": "planned"}

    def draft_node(state: WorkflowState) -> dict[str, str]:
        draft = ask(
            "Act as a concise writer. Use the plan to answer the brief in "
            "no more than 180 words. Return only the draft.\n\n"
            f"Brief: {state['task']}\n\n"
            f"Plan: {state['plan']}"
        )
        return {"draft": draft, "status": "drafted"}

    builder = StateGraph(WorkflowState)
    builder.add_node("plan", plan_node)
    builder.add_node("draft", draft_node)
    builder.add_edge(START, "plan")
    builder.add_edge("plan", "draft")
    builder.add_edge("draft", END)

    return builder.compile()


def display_update(node_name: str, update: dict) -> None:
    print(f"\n[{node_name.upper()}]")
    for key in ("plan", "draft", "review", "status", "revision_count"):
        if key in update:
            print(f"{key.upper()}: {update[key]}")


def main() -> None:
    task = input("Brief: ").strip() or DEFAULT_TASK
    graph = build_graph()

    initial_state: WorkflowState = {
        "task": task,
        "plan": "",
        "draft": "",
        "review": "",
        "status": "new",
        "revision_count": 0,
    }
    final_state = dict(initial_state)

    for chunk in graph.stream(
        initial_state,
        stream_mode="updates",
        version="v2",
    ):
        if chunk["type"] != "updates":
            continue
        for node_name, update in chunk["data"].items():
            final_state.update(update)
            display_update(node_name, update)

    print("\nFINAL DRAFT\n")
    print(final_state["draft"])


if __name__ == "__main__":
    main()

What should match?

Check the indentation inside build_graph() plus main() carefully. Python uses that indentation to keep node functions inside the graph builder.

Before you run the graph, which named update do you expect to appear first?

  • Run the orchestrator from the activated terminal with this command:
python orchestrator.py

What happens when this runs?

The script asks for a brief before starting the graph. Pressing Enter uses DEFAULT_TASK.

The graph calls the local model once for each node. Each returned update is printed before the final draft.

  • Press Enter at the Brief: prompt to use the default brief.

Local generation can pause while your CPU processes each prompt. A quiet terminal during a model call is expected.

You should see [PLAN] followed by [DRAFT] with different state updates. The terminal then prints FINAL DRAFT plus the generated launch plan.

The trace ends after the first draft. There is no review stage or routing decision, which exposes the linear graph's quality gap.

Missing the node updates?

  • Confirm the Ollama service is still running.
  • Check that display_update(node_name, update) remains inside the inner stream loop.
  • Compare the indentation in main() with the full-code tab.
  • Still blocked? Help me debug my streamed LangGraph updates.

That is the key shift complete. Your local model now runs inside an observable state graph. Next, you will add the quality gate that reviews the draft and controls one revision.

Add the Review and Revision Loop

Your LangGraph workflow now exposes planning and drafting as separate updates. The graph still accepts every first draft without evaluating it.

Without a quality gate, a weak answer reaches FINAL DRAFT unchanged. The workflow also lacks an explicit decision about whether the draft needs another pass.

In this step, get ready to:
  • Run the linear workflow to expose its missing decision point.
  • Turn draft feedback into shared state updates.
  • Route one bounded revision before completion.
Expose the missing quality gate

The current graph gives you a useful baseline for comparison. Its fixed path moves directly from drafting to completion.

Before you run the linear graph, make a mental prediction about whether any update will judge the draft.

  • Run the current workflow from the activated .venv by running this command:
python orchestrator.py

What does this command test?

This command runs the graph that currently connects planning directly to drafting. The streamed updates reveal every node that actually executes.

  • Press Enter at the Brief: prompt to use the default brief.

You will see [PLAN] followed by [DRAFT]. The script prints FINAL DRAFT without a [REVIEW] update.

The Missing Gate Is Intentional

The graph exposes its limitation clearly. Every first draft becomes the final result because no node evaluates its quality.

This visible shortfall gives the next additions a precise job. Feedback must become state that the graph can use for routing.

Build the reviewer and reviser

The reviewer needs to return a verdict that Python can inspect. The reviser needs to turn the feedback into a new draft.

  • In orchestrator.py, find the import line containing from typing import TypedDict.
  • Replace that import with this line:
from typing import Literal, TypedDict

Why Import Literal?

The Literal type limits the routing function to two named outcomes. Those outcomes will be revise or finish.

  • In orchestrator.py, find the line return {"draft": draft, "status": "drafted"} inside draft_node.
  • Add the reviewer directly below draft_node by pasting this function:
    def review_node(state: WorkflowState) -> dict[str, str]:
        model_review = ask(
            "Act as a strict reviewer. Check whether the draft fully answers "
            "the brief, follows the plan, is specific, and stays concise. "
            "Put PASS or REVISE on the first line, then give brief reasons.\n\n"
            f"Brief: {state['task']}\n\n"
            f"Plan: {state['plan']}\n\n"
            f"Draft: {state['draft']}"
        )

        first_line = model_review.splitlines()[0].strip().upper() if model_review else "REVISE"
        status = "pass" if first_line.startswith("PASS") else "revise"
        review = model_review

        if state["revision_count"] == 0:
            status = "revise"
            review = (
                "REVISE\n"
                "Demo gate: the first draft must pass through one visible revision.\n\n"
                f"Model review:\n{model_review}"
            )

        return {"review": review, "status": status}

How Does the Reviewer Work?

  • The model evaluates the draft against the original brief.
  • The first response line becomes either a pass or revise status.
  • A revision count of 0 activates the demonstration gate.
  • The function returns only the updated review and status fields.
  • Save orchestrator.py.
  • Confirm the editor shows review_node directly below draft_node.

Reviewer Function Misaligned?

Check that review_node has the same indentation as draft_node. Both functions belong inside build_graph.

Still stuck? Help me place review_node inside build_graph.

A review creates useful feedback only when another node can apply it. The reviser reads the existing state before replacing the draft.

  • Add the reviser directly below review_node by pasting this function:
    def revise_node(state: WorkflowState) -> dict[str, str | int]:
        revised_draft = ask(
            "Act as a reviser. Improve the draft using the review. Preserve "
            "useful content, fix every identified issue, stay under 180 words, "
            "and return only the revised draft.\n\n"
            f"Brief: {state['task']}\n\n"
            f"Plan: {state['plan']}\n\n"
            f"Current draft: {state['draft']}\n\n"
            f"Review: {state['review']}"
        )
        return {
            "draft": revised_draft,
            "revision_count": state["revision_count"] + 1,
            "status": "revised",
        }

How Does the Reviser Work?

  • The prompt supplies the original task to preserve the requested outcome.
  • The plan keeps the revision aligned with the earlier checkpoints.
  • The review tells the model which issues to fix.
  • The returned update increments revision_count to enforce the loop limit.
  • Save orchestrator.py.
  • Confirm the editor shows revise_node directly below review_node.

Reviser Function Showing an Error?

Check that the return type contains str | int. The update contains text fields plus the numeric revision count.

Still stuck? Help me fix my revise_node function.

Route one bounded revision

A conditional edge chooses the next node from the latest state. The bounded loop uses MAX_REVISIONS to guarantee termination.

  • Add the routing function directly below revise_node by pasting this code:
    def route_after_review(state: WorkflowState) -> Literal["revise", "finish"]:
        if state["status"] == "revise" and state["revision_count"] < MAX_REVISIONS:
            return "revise"
        return "finish"

How Does the Router Decide?

The router selects revise only when the reviewer requests changes. The current revision count must also stay below MAX_REVISIONS.

Every other state returns finish. That outcome sends execution to END.

  • Save orchestrator.py.
  • Confirm the function returns only revise or finish.

The new functions exist inside build_graph. The builder now needs to register them before the router can control execution.

  • In orchestrator.py, find the builder block beginning with builder = StateGraph(WorkflowState).
  • Replace that block through builder.add_edge("draft", END) with this completed topology:
    builder = StateGraph(WorkflowState)
    builder.add_node("plan", plan_node)
    builder.add_node("draft", draft_node)
    builder.add_node("review", review_node)
    builder.add_node("revise", revise_node)

    builder.add_edge(START, "plan")
    builder.add_edge("plan", "draft")
    builder.add_edge("draft", "review")
    builder.add_conditional_edges(
        "review",
        route_after_review,
        {"revise": "revise", "finish": END},
    )
    builder.add_edge("revise", "review")

What Does This Topology Do?

  • The fixed edges preserve the planning and drafting sequence.
  • The draft now flows into review.
  • The conditional edge maps the routing result to revise or END.
  • The final fixed edge sends a revised draft back through the reviewer.
  • Save orchestrator.py.
  • Confirm the builder registers four node names.

The loop can finish while the reviewer still requests another revision because the guard allows only one pass. A final message makes that capped state visible.

  • Find print(final_state["draft"]) near the end of main.
  • Add this guard directly below that line:
    if final_state["status"] == "revise":
        print("\nStopped after the configured maximum number of revisions.")

Why Show the Stopping Message?

The graph always respects the configured limit. This message explains why execution stops when the second review still requests changes.

  • Save orchestrator.py.

✔️ Awesome, I've got everything!

Your incremental edits now match the completed draft-and-review workflow.

ⓧ I'd like to double check the full code

  • Compare your saved orchestrator.py with this complete reference:
from typing import Literal, TypedDict

from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph

MODEL = "qwen3:4b"
MAX_REVISIONS = 1
DEFAULT_TASK = (
    "Write a launch plan for a local AI study group. "
    "Include the audience, three actions, and one risk."
)


class WorkflowState(TypedDict):
    task: str
    plan: str
    draft: str
    review: str
    status: str
    revision_count: int


def build_graph():
    model = ChatOllama(
        model=MODEL,
        temperature=0.2,
        num_predict=300,
        reasoning=False,
    )

    def ask(prompt: str) -> str:
        response = model.invoke(prompt)
        if isinstance(response.content, str):
            return response.content.strip()
        return str(response.content).strip()

    def plan_node(state: WorkflowState) -> dict[str, str]:
        plan = ask(
            "Act as a planner. Create exactly three numbered checkpoints "
            "for the brief below. Keep the plan under 90 words.\n\n"
            f"Brief: {state['task']}"
        )
        return {"plan": plan, "status": "planned"}

    def draft_node(state: WorkflowState) -> dict[str, str]:
        draft = ask(
            "Act as a concise writer. Use the plan to answer the brief in "
            "no more than 180 words. Return only the draft.\n\n"
            f"Brief: {state['task']}\n\n"
            f"Plan: {state['plan']}"
        )
        return {"draft": draft, "status": "drafted"}

    def review_node(state: WorkflowState) -> dict[str, str]:
        model_review = ask(
            "Act as a strict reviewer. Check whether the draft fully answers "
            "the brief, follows the plan, is specific, and stays concise. "
            "Put PASS or REVISE on the first line, then give brief reasons.\n\n"
            f"Brief: {state['task']}\n\n"
            f"Plan: {state['plan']}\n\n"
            f"Draft: {state['draft']}"
        )

        first_line = model_review.splitlines()[0].strip().upper() if model_review else "REVISE"
        status = "pass" if first_line.startswith("PASS") else "revise"
        review = model_review

        if state["revision_count"] == 0:
            status = "revise"
            review = (
                "REVISE\n"
                "Demo gate: the first draft must pass through one visible revision.\n\n"
                f"Model review:\n{model_review}"
            )

        return {"review": review, "status": status}

    def revise_node(state: WorkflowState) -> dict[str, str | int]:
        revised_draft = ask(
            "Act as a reviser. Improve the draft using the review. Preserve "
            "useful content, fix every identified issue, stay under 180 words, "
            "and return only the revised draft.\n\n"
            f"Brief: {state['task']}\n\n"
            f"Plan: {state['plan']}\n\n"
            f"Current draft: {state['draft']}\n\n"
            f"Review: {state['review']}"
        )
        return {
            "draft": revised_draft,
            "revision_count": state["revision_count"] + 1,
            "status": "revised",
        }

    def route_after_review(state: WorkflowState) -> Literal["revise", "finish"]:
        if state["status"] == "revise" and state["revision_count"] < MAX_REVISIONS:
            return "revise"
        return "finish"

    builder = StateGraph(WorkflowState)
    builder.add_node("plan", plan_node)
    builder.add_node("draft", draft_node)
    builder.add_node("review", review_node)
    builder.add_node("revise", revise_node)

    builder.add_edge(START, "plan")
    builder.add_edge("plan", "draft")
    builder.add_edge("draft", "review")
    builder.add_conditional_edges(
        "review",
        route_after_review,
        {"revise": "revise", "finish": END},
    )
    builder.add_edge("revise", "review")

    return builder.compile()


def display_update(node_name: str, update: dict) -> None:
    print(f"\n[{node_name.upper()}]")
    for key in ("plan", "draft", "review", "status", "revision_count"):
        if key in update:
            print(f"{key.upper()}: {update[key]}")


def main() -> None:
    task = input("Brief: ").strip() or DEFAULT_TASK
    graph = build_graph()

    initial_state: WorkflowState = {
        "task": task,
        "plan": "",
        "draft": "",
        "review": "",
        "status": "new",
        "revision_count": 0,
    }
    final_state = dict(initial_state)

    for chunk in graph.stream(
        initial_state,
        stream_mode="updates",
        version="v2",
    ):
        if chunk["type"] != "updates":
            continue
        for node_name, update in chunk["data"].items():
            final_state.update(update)
            display_update(node_name, update)

    print("\nFINAL DRAFT\n")
    print(final_state["draft"])
    if final_state["status"] == "revise":
        print("\nStopped after the configured maximum number of revisions.")


if __name__ == "__main__":
    main()

What Should Match?

This reference contains the complete Qwen-based workflow. Its state fields, node functions, edges, router, stream handling, and stopping message match the final project artifact.

The script pauses at Brief: before the graph starts. Each model-backed node can create a short pause while your computer generates its response locally.

Before you run the completed graph, make a mental prediction about the route that will appear between drafting and the final result.

  • Run the completed workflow from the activated .venv by running this command:
python orchestrator.py

What Does This Run Prove?

This run executes the compiled graph with streamed state updates. The trace reveals each node selected by the fixed edges and conditional route.

  • Press Enter at the Brief: prompt to submit the default brief.

You will see [PLAN], [DRAFT], [REVIEW], [REVISE], and a second [REVIEW] before FINAL DRAFT.

That is the loop working. Your graph now evaluates every first draft before producing its final result.

Trace Stops Before the Final Draft?

Check that draft connects to review. Check that revise connects back to review.

Confirm the running Ollama service still has qwen3:4b available.

Still stuck? Help me debug my review and revision trace.

Secret mission

Secret Mission: Swap Qwen for Llama Without Rebuilding the Graph

Your graph already runs a complete draft-and-review workflow with Qwen. Can you make the model selectable and run the unchanged graph with Llama 3.2 3B?

Clean Up Your Resources

Clean Up Your Resources

Everything you built stays on your Linux computer, with no cloud charges. Choose whether to keep everything, pause the local model service, or delete the project resources.

Resources you used:

  • The current project folder open in Visual Studio Code. It contains requirements.txt, baseline.py, orchestrator.py, and the disposable .venv environment.
  • The downloaded Qwen model qwen3:4b.
  • The downloaded Llama model llama3.2:3b.
  • The running Ollama service.

Keep everything running

No action needed. Choose this if you want to test more briefs or extend the graph with new nodes.

  • Your project files remain available in the current folder.
  • Your .venv keeps the pinned Python packages ready for the next run.
  • Both local models remain available for comparisons.
  • The Ollama service keeps serving local requests from baseline.py and orchestrator.py.
  • Your Ollama installation remains available for future local AI projects.

Pause - I'll come back to this later

Pause the local model service to free memory while keeping your files and downloaded models ready for later.

  • Stop the systemd service by running this command if that is how Ollama is running:
sudo systemctl stop ollama

The Ollama service stops. Your project files and model downloads remain on disk.

  • Return to the separate terminal that is running the Ollama server if you started it there.
  • Stop the running server process.

Your local workflow is now paused. You can restart Ollama when you return.

Delete - I don't want to use this again

Deletion permanently removes your project files and downloaded models. The Ollama installation remains on your machine.

That cleanup is deliberately narrow. Unrelated Ollama models stay intact.

  • List the installed model tags by running this command:
ollama ls

You should see qwen3:4b and llama3.2:3b in the model list.

  • Open the Ollama CLI reference to find the documented remove-model form.
  • Use the remove-model form with the installed tag qwen3:4b.
  • Use the same form with the installed tag llama3.2:3b.
  • Confirm both project models are gone by running this command:
ollama ls

You should no longer see qwen3:4b or llama3.2:3b in the model list.

  • Stop Ollama by running this command if your installation uses systemd:
sudo systemctl stop ollama

The local model service stops while the Ollama installation stays available.

  • Return to the separate terminal running the Ollama server if you did not use systemd.
  • Stop the running server process.
  • Use your Linux file manager to locate the project folder currently open in Visual Studio Code.
  • Close the current project in Visual Studio Code.
  • Delete that project folder in your Linux file manager.
  • Confirm the project folder no longer appears in the file manager.

The project folder is now gone. Its .venv and Python files were removed with it.

Nice Work!

Nice Work!

You did it! Your local draft-and-review orchestrator now turns a brief into a reviewed final draft through a visible LangGraph workflow.

You've learned how to:

  • Build a direct local model call through Ollama. Use its single response to expose the hidden workflow.
  • Define shared graph state with WorkflowState. Pass partial updates between the plan and draft nodes through fixed edges.
  • Add a bounded review loop with conditional routing. Guard it with MAX_REVISIONS. Stream each node update until the final draft appears.
  • Complete the optional Secret Mission by making model selection configurable. Reuse the unchanged graph with qwen3:4b or llama3.2:3b.

Ready to quiz yourself?