Agentic AI Engineering with LangChain / Lab 07
Module 07 · LangGraph fundamentals

Lab 07 · Ticket triage pipeline

Build a LangGraph workflow that classifies a ticket in parallel, routes it to one of three desks, and drafts a reply in a grade-and-revise loop.

60 minIntermediateAzure OpenAIStateGraphreducerparallel nodesCommandconditional edgeRuntime context

What you will build

A support-ticket pipeline written as a single LangGraph StateGraph. Three classifiers (category, urgency, sentiment) run in parallel, a triage node routes the ticket to the billing, technical or account desk, and a draft and grade loop rewrites the reply until a grader approves it or a revision budget runs out.

What you will learn

  • How a StateGraph passes one shared state between nodes
  • How nodes return partial updates, and how a reducer merges list updates
  • How fan-out and fan-in edges run nodes in parallel
  • How Command updates state and routes in one step
  • How a conditional edge builds a loop with a hard stop
  • How context= passes run settings that never enter the state
1

Check your prerequisites

You need three things on your machine and three values from your instructor.

  • Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
  • A code editor. VS Code with the Python extension works well.
  • A terminal. PowerShell on Windows, Terminal on macOS or Linux.
PowerShell
python --version
i

From your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.

2

Create the project folder and virtual environment

Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.

PowerShell
mkdir C:\labs\lab-07-triage-pipeline
cd C:\labs\lab-07-triage-pipeline
python -m venv .venv
.venv\Scripts\Activate.ps1
✓

Keep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.

When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.

!

PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.

This is what your folder will look like at the end of the lab:

lab-07-triage-pipeline/ ├── .env ├── model_setup.py ├── requirements.txt └── triage_pipeline.py
3

Install the libraries

Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.

requirements.txt
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
langgraph==1.2.11

langgraph is the graph runtime underneath create_agent. In this lab you use it directly.

Then install everything into your virtual environment. It takes a minute or two.

PowerShell
python -m pip install --upgrade pip
pip install -r requirements.txt
4

Add your Azure OpenAI settings

Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.

.env
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>

# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview
  • AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in /openai/v1, keep it that way. If it is just https://<resource>.openai.azure.com/, paste that; the helper in the next step handles both.
  • AZURE_OPENAI_API_KEY is your secret key.
  • AZURE_OPENAI_DEPLOYMENT is the deployment name, for example gpt-5-mini. On Azure you call a deployment, not a model name.
!

Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.

5

Create the Azure OpenAI helper

Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.

Block 1Imports, .env loading and a console fix

load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.

The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.

model_setup.py (add to the end of the file)
"""Azure OpenAI model setup shared by every lab.

Import it in lab code as:  from model_setup import get_model, get_embeddings
"""

import asyncio
import os
import sys

from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel

# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()

# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
    if hasattr(_stream, "reconfigure"):
        _stream.reconfigure(encoding="utf-8", errors="replace")

Block 2Read settings safely

_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.

_uses_v1_api() checks the shape of your endpoint. Azure offers two:

  • https://<resource>.openai.azure.com/openai/v1 is the newer OpenAI-compatible API.
  • https://<resource>.openai.azure.com/ is the classic API that needs an api_version.
model_setup.py (add to the end of the file)
def _setting(name: str) -> str:
    """Return a required setting from .env, or stop with a readable message."""
    value = os.getenv(name, "").strip()
    if not value:
        sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
    return value


def _uses_v1_api(endpoint: str) -> bool:
    """True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
    return endpoint.rstrip("/").endswith("/openai/v1")

Block 3Build the chat model

get_model() returns a LangChain chat model connected to your deployment.

  • With a /openai/v1 endpoint it uses ChatOpenAI with base_url. On Azure the deployment name goes in model.
  • With a classic endpoint it uses AzureChatOpenAI with azure_deployment and api_version.

Either way the lab code just calls get_model() and never cares which one it got.

model_setup.py (add to the end of the file)
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
    """Return a chat model for your Azure OpenAI deployment."""
    endpoint = _setting("AZURE_OPENAI_ENDPOINT")
    api_key = _setting("AZURE_OPENAI_API_KEY")
    name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")

    if _uses_v1_api(endpoint):
        from langchain_openai import ChatOpenAI

        return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)

    from langchain_openai import AzureChatOpenAI

    return AzureChatOpenAI(
        azure_deployment=name,
        azure_endpoint=endpoint,
        api_key=api_key,
        api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
        **kwargs,
    )

Block 4Build the embeddings model

get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.

model_setup.py (add to the end of the file)
def get_embeddings(deployment: str | None = None) -> Embeddings:
    """Return an embeddings model for your Azure OpenAI embeddings deployment."""
    endpoint = _setting("AZURE_OPENAI_ENDPOINT")
    api_key = _setting("AZURE_OPENAI_API_KEY")
    name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")

    if _uses_v1_api(endpoint):
        from langchain_openai import OpenAIEmbeddings

        return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)

    from langchain_openai import AzureOpenAIEmbeddings

    return AzureOpenAIEmbeddings(
        azure_deployment=name,
        azure_endpoint=endpoint,
        api_key=api_key,
        api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
    )

Block 5A Windows fix for async code

use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.

model_setup.py (add to the end of the file)
def use_stable_event_loop() -> None:
    """On Windows, use the Selector event loop so async MCP code exits cleanly."""
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

Check that the helper can reach your deployment with a one-line test:

PowerShell
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"

You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.

6

Write the triage pipeline

In earlier labs create_agent decided what happens next. Here you draw the flow as a graph, which makes it predictable and testable:

  • A node is a Python function. It reads the state and returns a dict with only the keys it changes.
  • An edge says which node runs next. Nodes reachable in the same step run in parallel.

This is the graph you are building:

  • START fans out to classify_category, classify_urgency and classify_sentiment.
  • All three fan in to triage, which jumps to billing, technical or account.
  • Each desk goes to draft_reply, then grade_reply, which either loops back to draft_reply or ends.

Only three nodes call the model: classify_sentiment, draft_reply and grade_reply. Everything else is plain Python.

Create an empty file named triage_pipeline.py in your lab folder. Then copy each block below into it, in order, one after another.

Block 1Imports

  • StateGraph, START and END build the graph.
  • Command lets a node update state and choose the next node.
  • Runtime gives a node access to the per-run context.
  • operator provides operator.add, used as a reducer below.
triage_pipeline.py (add to the end of the file)
"""Lab 07: the Northwind ticket triage pipeline as one LangGraph StateGraph.

Run:  python triage_pipeline.py
"""

import operator
from dataclasses import dataclass
from typing import Annotated, Any, Callable, Literal

from langchain_core.language_models import BaseChatModel
from langgraph.graph import END, START, StateGraph
from langgraph.graph.state import CompiledStateGraph
from langgraph.runtime import Runtime
from langgraph.types import Command
from typing_extensions import TypedDict

from model_setup import get_model

Block 2Business rules as data

Keywords for the category and urgency classifiers, and one playbook per desk. The playbook is the instruction the draft must follow. Keeping rules in dictionaries makes them easy to read and change without touching the graph.

triage_pipeline.py (add to the end of the file)
CATEGORY_KEYWORDS = {
    "billing": ("invoice", "charge", "refund", "payment"),
    "technical": ("error", "crash", "timeout", "500", "bug"),
    "account": ("login", "password", "sso", "seat", "access"),
}
URGENT_WORDS = ("outage", "urgent", "asap", "block", "production")
PLAYBOOKS = {
    "billing": "Confirm the invoice number and say whether a refund was issued.",
    "technical": "Acknowledge the error, name the component, give the next step.",
    "account": "Verify identity first, then explain the access change.",
}

Block 3The state and the run context

TriageState is the shared memory of one run. total=False means every key is optional, because nodes fill them in over time.

  • Each classifier writes its own key. Parallel nodes that write the same key would fail with InvalidUpdateError.
  • feedback has a reducer: Annotated[list[str], operator.add]. Without it, each new value replaces the old one. With it, LangGraph appends, so the grader's instructions accumulate across rounds.

TriageContext holds settings for the run (tenant, agent name, revision budget). You pass it with context=; it is read-only and never stored in state.

triage_pipeline.py (add to the end of the file)
class TriageState(TypedDict, total=False):
    """Everything the pipeline knows about one ticket."""

    ticket: str
    category: str  # one key per classifier, so the parallel writes never collide
    urgency: str
    sentiment: str
    handler: str
    playbook: str
    draft: str
    attempts: int
    approved: bool
    feedback: Annotated[list[str], operator.add]  # one instruction per grading round


@dataclass
class TriageContext:
    """Per-run configuration: passed as `context=`, never stored in state."""

    tenant: str = "northwind"
    agent_name: str = "Ada"
    max_revisions: int = 2

Block 4Two keyword classifiers

These nodes are plain Python: free, instant and deterministic. Each returns a partial update, for example {"urgency": "high"}. LangGraph merges it into the state for you.

triage_pipeline.py (add to the end of the file)
def classify_category(state: TriageState) -> dict[str, Any]:
    """Keyword scoring: deterministic, free, and good enough for a first pass."""
    text = state["ticket"].lower()
    hits = {name: sum(w in text for w in words) for name, words in CATEGORY_KEYWORDS.items()}
    best = max(hits, key=lambda name: hits[name])
    return {"category": best if hits[best] else "technical"}


def classify_urgency(state: TriageState) -> dict[str, Any]:
    """Count urgent words: two or more is high, one is normal, none is low."""
    hits = sum(word in state["ticket"].lower() for word in URGENT_WORDS)
    return {"urgency": "high" if hits >= 2 else "normal" if hits else "low"}

Block 5The router: pick_handler and triage

pick_handler is the routing policy in a plain function, so it is easy to test. A high-urgency, negative ticket goes to the account desk (churn risk), whatever it is about.

triage returns a Command that writes handler and jumps to that desk in one step. The return type Command[Literal["billing", "technical", "account"]] is not decoration: LangGraph reads it to know the possible destinations and to draw them in the diagram.

triage_pipeline.py (add to the end of the file)
def pick_handler(state: TriageState) -> str:
    """Churn risk goes to the account desk whatever the ticket is about."""
    if state["urgency"] == "high" and state["sentiment"] == "negative":
        return "account"
    return state["category"] if state["category"] in PLAYBOOKS else "technical"


def triage(state: TriageState) -> Command[Literal["billing", "technical", "account"]]:
    """Write the decision and jump to it in one atomic step."""
    handler = pick_handler(state)
    return Command(update={"handler": handler}, goto=handler)

Block 6The loop exit

should_revise is a conditional edge: it returns the name of the next node. It ends the run when the grader approved, or when attempts passed max_revisions from the context. The second exit is the safety net: a loop that depends only on a model saying yes could run forever.

With max_revisions=2 a ticket gets at most three drafts.

triage_pipeline.py (add to the end of the file)
def should_revise(
    state: TriageState, runtime: Runtime[TriageContext]
) -> Literal["draft_reply", "__end__"]:
    """Two exits: the grader approved, or the revision budget is spent."""
    if state["approved"] or state["attempts"] > runtime.context.max_revisions:
        return END
    return "draft_reply"

Block 7The three model nodes

make_model_nodes builds the nodes that call the model, so they can share one model object.

  • classify_sentiment searches the answer for one of three words and falls back to neutral. Models do not reliably answer with exactly one word.
  • draft_reply reads the playbook, the ticket and all feedback so far, and increments attempts. It takes runtime to read the agent name and tenant.
  • grade_reply approves, or returns REVISE: <instruction>. The instruction is appended to feedback thanks to the reducer.
triage_pipeline.py (add to the end of the file)
def make_model_nodes(model: BaseChatModel) -> tuple[Callable[..., dict[str, Any]], ...]:
    """Return the three nodes that call the model: sentiment, draft and grade."""

    def classify_sentiment(state: TriageState) -> dict[str, Any]:
        """Ask the model for the tone and normalise it to one of three words."""
        question = "Classify the customer's tone. One word: positive, neutral or negative."
        answer = model.invoke(f"{question}\n\n{state['ticket']}").text.lower()
        tones = ("negative", "positive", "neutral")
        return {"sentiment": next((tone for tone in tones if tone in answer), "neutral")}

    def draft_reply(state: TriageState, runtime: Runtime[TriageContext]) -> dict[str, Any]:
        """Write (or rewrite) the reply, following the playbook and any feedback."""
        context = runtime.context
        prompt = (
            f"You are {context.agent_name}, a support agent at {context.tenant}. "
            f"Write a short reply.\nPlaybook: {state['playbook']}\nTicket: {state['ticket']}\n"
            f"Reviewer feedback to address: {'; '.join(state['feedback'])}"
        )
        return {"draft": model.invoke(prompt).text.strip(), "attempts": state["attempts"] + 1}

    def grade_reply(state: TriageState) -> dict[str, Any]:
        """Approve the draft, or send it back with one concrete instruction."""
        prompt = (
            'Reply "APPROVE" if the draft follows the playbook and answers the ticket, '
            'otherwise "REVISE: <one concrete instruction>".\n'
            f"Playbook: {state['playbook']}\nDraft: {state['draft']}"
        )
        verdict = model.invoke(prompt).text.strip()
        if verdict.upper().startswith("APPROVE"):
            return {"approved": True}
        return {"approved": False, "feedback": [verdict.partition(":")[2].strip()]}

    return classify_sentiment, draft_reply, grade_reply

Block 8Wire the graph

  • The loop adds an edge from START to each classifier (fan-out) and from each classifier to triage (fan-in). triage waits for all three.
  • Each desk is a tiny node that sets the playbook, then goes to draft_reply. text=playbook in the lambda captures the current value; without it every desk would get the last playbook.
  • add_conditional_edges closes the loop. The list ["draft_reply", END] declares the possible targets for validation and the diagram.

There is no edge out of triage: its Command does the routing.

triage_pipeline.py (add to the end of the file)
def build_graph(model: BaseChatModel | None = None) -> CompiledStateGraph:
    """Compile the pipeline. Pass a model to override the Azure OpenAI default."""
    classify_sentiment, draft_reply, grade_reply = make_model_nodes(model or get_model())

    builder = StateGraph(TriageState, context_schema=TriageContext)
    builder.add_node("classify_category", classify_category)
    builder.add_node("classify_urgency", classify_urgency)
    builder.add_node("classify_sentiment", classify_sentiment)
    builder.add_node("triage", triage)
    builder.add_node("draft_reply", draft_reply)
    builder.add_node("grade_reply", grade_reply)

    for classifier in ("classify_category", "classify_urgency", "classify_sentiment"):
        builder.add_edge(START, classifier)  # fan-out: one super-step, three nodes
        builder.add_edge(classifier, "triage")  # fan-in: triage waits for all three
    for desk, playbook in PLAYBOOKS.items():
        # each desk is one node whose whole job is to choose the playbook the draft follows
        builder.add_node(desk, lambda state, text=playbook: {"playbook": text})
        builder.add_edge(desk, "draft_reply")
    builder.add_edge("draft_reply", "grade_reply")
    builder.add_conditional_edges("grade_reply", should_revise, ["draft_reply", END])

    return builder.compile(name="northwind-triage")

Block 9Run one ticket and watch every step

The demo prints the graph as a Mermaid diagram, then streams the run.

  • stream_mode=["updates", "values"] yields two kinds of events: updates (what each node returned) and values (the full state after each step). The last values event is the final state.
  • The initial state sets attempts: 0 and feedback: [], because the nodes read them before anything writes them.

The sample ticket is about an invoice, but it is urgent and angry, so watch where it is routed.

triage_pipeline.py (add to the end of the file)
SAMPLE_TICKET = (
    "We were charged twice for invoice INV-2291 this morning and the refund button returns a "
    "500 error. This is blocking our month-end close. Please fix it ASAP."
)


def short(value: Any, width: int = 70) -> str:
    """One-line preview of a state value."""
    text = str(value).replace("\n", " ")
    return text if len(text) <= width else text[: width - 3] + "..."


if __name__ == "__main__":
    graph = build_graph()
    print(graph.get_graph().draw_mermaid())

    inputs: TriageState = {"ticket": SAMPLE_TICKET, "attempts": 0, "feedback": []}
    result: dict[str, Any] = {}
    for mode, chunk in graph.stream(inputs, context=TriageContext(), stream_mode=["updates", "values"]):
        if mode == "values":
            result = chunk  # the full state after each step; the last one is the final state
            continue
        for node, update in chunk.items():
            fields = ", ".join(f"{key}={short(value, 50)}" for key, value in (update or {}).items())
            print(f"[{node}] {fields}")

    print(f"\n{result['handler']} desk · {result['attempts']} draft(s) · approved={result.get('approved')}")
    print(result["draft"])
7

Run the pipeline

PowerShell
python triage_pipeline.py

Your output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.

Expected output
---
config:
  flowchart:
    curve: linear
---
graph TD;
	__start__([<p>__start__</p>]):::first
	classify_category(classify_category)
	classify_urgency(classify_urgency)
	classify_sentiment(classify_sentiment)
	triage(triage)
	draft_reply(draft_reply)
	grade_reply(grade_reply)
	billing(billing)
	technical(technical)
	account(account)
	__end__([<p>__end__</p>]):::last
	__start__ --> classify_category;
	__start__ --> classify_sentiment;
	__start__ --> classify_urgency;
	account --> draft_reply;
	billing --> draft_reply;
	classify_category --> triage;
	classify_sentiment --> triage;
	classify_urgency --> triage;
	draft_reply --> grade_reply;
	grade_reply -.-> __end__;
	grade_reply -.-> draft_reply;
	technical --> draft_reply;
	triage -.-> account;
	triage -.-> billing;
	triage -.-> technical;
	classDef default fill:#f2f0ff,line-height:1.2
	classDef first fill-opacity:0
	classDef last fill:#bfb6fc

[classify_category] category=billing
[classify_urgency] urgency=high
[classify_sentiment] sentiment=negative
[triage] handler=account
[account] playbook=Verify identity first, then explain the access ...
[draft_reply] draft=Hi — I’m Ada. I can take care of this right awa..., attempts=1
[grade_reply] approved=True

account desk · 1 draft(s) · approved=True
Hi — I’m Ada. I can take care of this right away, but first I need to verify you.

Please confirm the account email or company name on the Northwind account and the last 4 digits of the payment method used for INV-2291 (and the approximate time the two charges hit). Once you reply, I will:

- Temporarily enable a backend refund override for your account so I can issue the refund immediately (this won’t change your normal permissions and will be revoked after the issue is resolved).
- Manually process the duplicate refund and escalate the 500 error to engineering so the refund button is fixed.

I’ll aim to issue the refund within 2 hours after verification and will keep you updated.

Read the output from top to bottom:

  • The Mermaid diagram comes first. Paste it into mermaid.live and check that triage has three dotted edges, one per desk.
  • The three classifiers print one after another, but they ran in the same step. triage only ran after all three finished.
  • The ticket mentions an invoice and a refund, so category is billing. But urgency is high and sentiment is negative, so triage routes it to the account desk.
  • draft_reply and grade_reply may repeat. Each REVISE adds one item to feedback, and the run stops at approval or after three drafts. The number of rounds varies from run to run.
8

Check your work

You are done when

  • The Mermaid diagram shows triage with dotted edges to billing, technical and account.
  • category, urgency and sentiment all appear before [triage].
  • The sample ticket reaches the account desk although its category is billing.
  • The summary line shows between 1 and 3 drafts.

Troubleshooting

InvalidUpdateError: At key '...': Can receive only one value per step

Two parallel nodes wrote the same key. Each classifier must return only its own key (category, urgency or sentiment).

KeyError: 'attempts' or 'feedback'

The initial state must include "attempts": 0 and "feedback": [].

The diagram shows no edges out of triage

The Command[Literal[...]] return annotation on triage is missing, so LangGraph cannot see its destinations.

AttributeError: 'NoneType' object has no attribute 'max_revisions'

Pass context=TriageContext() (or context={}) when you call stream or invoke.

401 Unauthorized or 'Access denied due to invalid subscription key'

The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.

404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'

AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.

429 Too Many Requests

The shared deployment is busy. Wait 30 seconds and run again.

ModuleNotFoundError: No module named 'model_setup' (or another package)

Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.

'python' is not recognized, or the wrong Python runs

Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.

Go further

  • Write a friendly ticket such as Thanks, just a question about my invoice and check it reaches the billing desk.
  • Run with context=TriageContext(max_revisions=0) and confirm the loop stops after one draft.
  • Give feedback a reducer that keeps only the last three instructions: lambda left, right: (left + right)[-3:].
  • Add a CachePolicy(ttl=300) to classify_sentiment and compile with cache=InMemoryCache(). Re-running the same ticket should skip that model call.
  • Rewrite the pipeline with the functional API (@entrypoint and @task) and compare the two versions.

Stuck?

Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.