Lab 15 · Ship it
Turn the Northwind support agent into a service other people can call, twice: on the local Agent Server with langgraph dev, and on a licence-free FastAPI wrapper you own.
What you will build
You take the Northwind support agent from a graph that runs on my laptop to a service other people can call, two ways:
- Agent Server. You package the agent as an
app/folder pluslanggraph.json, serve it withlanggraph dev, and drive the server's data model from the Python SDK: assistant, thread, streaming run, background run and cron. - Your own FastAPI wrapper. You serve the same graph from about eighty lines of FastAPI, with API-key auth, Server-Sent Events streaming and SQLite persistence, and prove that a conversation survives a restart.
Doing both is the point: at the end you can say which one you would choose for a given team, and why.
What you will learn
- How to package a graph for deployment with a no-argument factory and
langgraph.json - How the Agent Server models work: assistants, threads, runs and crons
- How to stream, queue and join runs with the
langgraph_sdkclient - How to mount custom routes into the Agent Server
- What you take over when you write your own wrapper: persistence, auth and streaming
You use two terminals in this lab: one runs a server, the other runs the client. Activate the virtual environment in both.
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.
python --versionpython3 --versionFrom 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.
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.
mkdir C:\labs\lab-15-ship-it
cd C:\labs\lab-15-ship-it
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-15-ship-it
cd ~/labs/lab-15-ship-it
python3 -m venv .venv
source .venv/bin/activateKeep 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:
Files marked as generated are created when you run the code. You do not create them yourself.
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.
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
langgraph==1.2.11
langgraph-cli[inmem]==0.4.31
langgraph-api==0.14.0
langgraph-sdk==0.4.4
langgraph-checkpoint-sqlite==3.1.1
aiosqlite==0.22.1
fastapi==0.141.1
uvicorn==0.52.4
httpx==0.28.1
colorama==0.4.6langgraph-cli[inmem]gives you thelanggraphcommand and the in-memory Agent Server (langgraph-api) behindlanggraph dev. The dev server needs no licence key and no LangSmith account.langgraph-sdkis the Python client for the server.fastapi,uvicorn,langgraph-checkpoint-sqliteandaiosqliterun your own wrapper, andhttpxis the HTTP client for the test script.coloramais needed on Windows: the server's coloured console log crashes at startup without it (ConsoleRenderer with colors=True on Windows requires the colorama package).
This install is larger than in earlier labs and can take a few minutes.
Then install everything into your virtual environment. It takes a minute or two.
python -m pip install --upgrade pip
pip install -r requirements.txtpython -m pip install --upgrade pip
pip install -r requirements.txtAdd 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.
# 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 justhttps://<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/.
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.
"""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/v1is the newer OpenAI-compatible API.https://<resource>.openai.azure.com/is the classic API that needs anapi_version.
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/v1endpoint it usesChatOpenAIwithbase_url. On Azure the deployment name goes inmodel. - With a classic endpoint it uses
AzureChatOpenAIwithazure_deploymentandapi_version.
Either way the lab code just calls get_model() and never cares which one it got.
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.
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.
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:
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"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.
Create the app package
A deployable agent is a normal Python package. Create a folder named app inside your lab folder, and in it an almost empty __init__.py. It marks app as a package, so wrapper.py can later write from app.agent import build_agent.
"""The deployable Northwind package: the graph and its custom routes."""Write the deployable agent
This is the Northwind support agent with three tools. The important part is how it is built: through a factory function the server can call.
Create an empty file named app/agent.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
CompiledStateGraph is the type create_agent returns; it is only used for type hints. get_model comes from your helper in the lab folder, one level above app/. The server can import it because langgraph.json lists the lab folder as a dependency (you add that in a moment).
"""The Northwind support agent, packaged for deployment.
langgraph.json points at make_graph below. The FastAPI wrapper (wrapper.py) uses build_agent
with its own checkpointer.
"""
from typing import Any
from langchain.agents import create_agent
from langchain.tools import tool
from langgraph.graph.state import CompiledStateGraph
from model_setup import get_modelBlock 2Data and prompt
Two dictionaries stand in for the order and customer systems. Each order names its customer, so a follow-up question like which customer placed it? can be answered from the conversation history.
ORDERS = {
"A-100": "shipped via DHL, arriving 2026-09-15, customer C-1",
"A-101": "processing, expected 2026-09-18, customer C-2",
"A-102": "delivered 2026-09-09, customer C-1",
}
CUSTOMERS = {
"C-1": "Acme GmbH, enterprise plan, EU",
"C-2": "Bluefin Ltd, starter plan, US",
}
SYSTEM_PROMPT = (
"You are Northwind Cloud's support agent. Answer order and account questions with the "
"tools, quote the order id in every answer, and keep replies under four sentences."
)Block 3Three tools
get_order_status and lookup_customer read the dictionaries. open_ticket pretends to open a support ticket; its docstring says to use it only when the customer asks to escalate.
@tool
def get_order_status(order_id: str) -> str:
"""Look up the shipping status of a Northwind order, such as A-100."""
return f"Order {order_id}: {ORDERS.get(order_id.upper(), 'not found')}."
@tool
def lookup_customer(customer_id: str) -> str:
"""Look up a Northwind customer's name, plan tier and region, such as C-1."""
return f"Customer {customer_id}: {CUSTOMERS.get(customer_id.upper(), 'not found')}."
@tool
def open_ticket(order_id: str, summary: str) -> str:
"""Open a support ticket for an order. Use only when the customer asks to escalate."""
return f"Ticket NW-4711 opened for {order_id}: {summary}"Block 4The builder and the factory
Two functions, on purpose:
build_agent(model, checkpointer)accepts the things a caller may want to inject. The FastAPI wrapper passes its own SQLite checkpointer; a test could pass a fake model.make_graph()is what the Agent Server calls. A deployed factory takes no arguments and passes no checkpointer, because the server supplies persistence itself.
Nothing in this file builds a model or opens a connection at import time. The server imports the module in every worker, so import-time work would slow every start.
def build_agent(model: Any = None, checkpointer: Any = None) -> CompiledStateGraph:
"""Build the agent. The wrapper passes a checkpointer; tests can pass a fake model."""
return create_agent(
model=model or get_model(),
tools=[get_order_status, lookup_customer, open_ticket],
system_prompt=SYSTEM_PROMPT,
checkpointer=checkpointer,
name="northwind-support",
)
def make_graph() -> CompiledStateGraph:
"""The factory langgraph.json points at.
It takes no arguments and passes no checkpointer: the Agent Server supplies persistence.
"""
return build_agent()Add custom routes (optional)
The Agent Server can mount your own FastAPI app next to its built-in routes. Use it for things that are not agent logic: health details, a version endpoint, a feedback proxy. This step is optional but short; if you skip it, also remove the http section from langgraph.json in the next step.
Create an empty file named app/server.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
A plain FastAPI app. BOOT_INFO holds a little startup information that the routes report.
"""Custom HTTP routes mounted into the Agent Server through langgraph.json ("http": {"app": ...})."""
import os
import time
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
BOOT_INFO: dict[str, str] = {}Block 2Lifespan
The lifespan context manager runs once per worker: code before yield on startup, code after it on shutdown. Here it records the revision and environment, read from environment variables a deployment would set.
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Run once per worker process: warm-up on the way in, cleanup on the way out."""
BOOT_INFO["revision"] = os.environ.get("NORTHWIND_REVISION", "dev")
BOOT_INFO["environment"] = os.environ.get("NORTHWIND_ENV", "local")
yield
BOOT_INFO.clear()
app = FastAPI(lifespan=lifespan)Block 3Middleware for every response
This middleware wraps every request, including the server's own /threads and /runs routes. It adds an x-request-id header (reusing the caller's if sent) and a server-timing header, which helps correlate logs during an incident.
@app.middleware("http")
async def request_context(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
"""Give every response, the server's own routes included, a correlation id and a timing."""
request_id = request.headers.get("x-request-id", str(uuid.uuid4()))
started = time.perf_counter()
response = await call_next(request)
response.headers["x-request-id"] = request_id
response.headers["server-timing"] = f"app;dur={(time.perf_counter() - started) * 1000:.1f}"
return responseBlock 4The routes
/northwind/infoanswers which revision is serving traffic?, the first question in a rollout./northwind/readyreports readiness of your dependencies; here it checks that a deployment name is configured./northwind/feedbackis a stub for the classic reason to add a route: proxying feedback so the browser never holds a LangSmith key.
Keep agent logic in the graph. Code here is invisible to threads, checkpoints and traces.
@app.get("/northwind/info")
async def info() -> dict[str, str]:
"""Which revision is actually serving traffic: the first question in a rollout."""
return {"service": "northwind-support", **BOOT_INFO}
@app.get("/northwind/ready")
async def ready() -> JSONResponse:
"""Readiness for *our* dependencies. GET /ok already covers the server itself."""
healthy = bool(BOOT_INFO) and bool(os.environ.get("AZURE_OPENAI_DEPLOYMENT"))
return JSONResponse({"ready": healthy}, status_code=200 if healthy else 503)
class Feedback(BaseModel):
"""A thumbs up or down from the UI."""
run_id: str
score: int
@app.post("/northwind/feedback")
async def feedback(body: Feedback) -> dict[str, str | int]:
"""Proxy feedback so the browser never holds a LangSmith key (a stub in this lab)."""
return {"recorded": body.run_id, "score": body.score}Write langgraph.json
langgraph.json tells the langgraph CLI what to serve. Create it in the lab folder (not inside app/).
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {
"support": "./app/agent.py:make_graph"
},
"env": ".env",
"http": {
"app": "./app/server.py:app"
}
}dependencies: ["."]adds the lab folder to the import path, which is howapp/agent.pyfindsmodel_setup.py. In a Docker build it is also what gets installed.graphsmaps a graph id (support) tofile:function. Pointing at a factory rather than a module-level graph keeps imports cheap.envloads your.envinto the server process.http.appmounts the custom routes fromapp/server.py.
You check this file with langgraph validate right before you start the server.
Write the SDK tour
The Agent Server exposes a small data model: an assistant is a named, versioned configuration of a graph; a thread is durable conversation state; a run is one piece of work on a thread; a cron is a run on a schedule. ship_it.py walks through all four with the synchronous SDK client.
Create an empty file named ship_it.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports and settings
get_sync_client returns a client for a running server. Its async twin, get_client, has the same methods. langgraph dev listens on port 2024 by default.
"""Lab 15: drive the local Agent Server through its whole data model with the Python SDK.
Start the server first (terminal 1): langgraph dev --no-browser
Then run (terminal 2): python ship_it.py
"""
import socket
from typing import Any
from langgraph_sdk import get_sync_client
SERVER_URL = "http://127.0.0.1:2024"
QUESTION = "Where is order A-100?"Block 2Step 1: an assistant
Every graph already has a default assistant named after it (support). You create your own to version a configuration: here a tenant value in configurable. if_exists="do_nothing" makes the call safe to repeat.
def create_assistant(client: Any) -> str:
"""Create (or reuse) a named, versioned configuration of the 'support' graph."""
assistant = client.assistants.create(
graph_id="support",
name="northwind-support-lab",
config={"configurable": {"tenant": "lab"}},
if_exists="do_nothing",
)
return assistant["assistant_id"]Block 3Step 2: a streaming run
client.runs.stream(...) starts a run and yields parts over Server-Sent Events while it works. Each part is a small named tuple with part.event and part.data. With stream_mode=["messages", "updates"] you get token chunks (messages... events) and a state update after each node. The function counts the message chunks.
def stream_run(client: Any, thread_id: str, assistant_id: str) -> int:
"""Start a streaming run on a thread and count the message chunks that arrive over SSE."""
chunks = 0
for part in client.runs.stream(
thread_id,
assistant_id,
input={"messages": [{"role": "user", "content": QUESTION}]},
stream_mode=["messages", "updates"],
):
if part.event.startswith("messages"):
chunks += 1
return chunksBlock 4Step 3: a background run
client.runs.create(...) returns as soon as the run is queued; the server works on it without anyone waiting. client.runs.join(...) blocks until it finishes. That is the pattern for email replies, batch jobs and anything a user does not watch live.
def background_run(client: Any, thread_id: str, assistant_id: str) -> str:
"""Queue a background run, which returns at once, then block until it settles."""
run = client.runs.create(
thread_id,
assistant_id,
input={"messages": [{"role": "user", "content": "What tier is customer C-1?"}]},
metadata={"job": "lab-background"},
)
client.runs.join(thread_id, run["run_id"])
return run["run_id"]Block 5Step 4: a cron
A cron is a background run on a schedule, here 07:00 on weekdays. The code deletes it immediately. Always clean up crons you create while experimenting, or they keep firing.
def schedule_and_cancel_cron(client: Any, assistant_id: str) -> str:
"""Create a weekday 07:00 cron, then delete it so it never fires."""
cron = client.crons.create(
assistant_id,
schedule="0 7 * * 1-5",
input={"messages": [{"role": "user", "content": "Daily digest of open orders."}]},
)
client.crons.delete(cron["cron_id"])
return cron["cron_id"]Block 6Put the tour together
sdk_tour takes the client as an argument instead of creating one. That makes it testable with a stub client, without a server.
After the runs it reads the thread state with client.threads.get_state(...). That state is what a page refresh would restore from. text_of handles message content that arrives as a string or as a list of parts.
def text_of(message: dict[str, Any]) -> str:
"""Return the plain text of a message dict (content can be a string or a list of parts)."""
content = message.get("content", "")
if isinstance(content, list):
content = "".join(p.get("text", "") for p in content if isinstance(p, dict))
return str(content)
def sdk_tour(client: Any) -> dict[str, Any]:
"""Walk assistant -> thread -> streaming run -> background run -> cron, and report."""
assistant_id = create_assistant(client)
thread_id = client.threads.create(metadata={"customer_id": "C-1"})["thread_id"]
chunks = stream_run(client, thread_id, assistant_id)
run_id = background_run(client, thread_id, assistant_id)
cron_id = schedule_and_cancel_cron(client, assistant_id)
messages = client.threads.get_state(thread_id)["values"].get("messages", [])
return {
"assistant_id": assistant_id,
"thread_id": thread_id,
"stream_chunks": chunks,
"background_run_id": run_id,
"cron_id": f"{cron_id} (deleted)",
"messages_in_thread": len(messages),
"last_reply": text_of(messages[-1]) if messages else "(none)",
}Block 7Run it
A quick socket check gives a readable message when the server is not running, instead of a connection-refused stack trace. Then it prints the summary.
if __name__ == "__main__":
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", 2024)) != 0:
raise SystemExit("No Agent Server on port 2024. Start it first: langgraph dev --no-browser")
summary = sdk_tour(get_sync_client(url=SERVER_URL))
for key, value in summary.items():
print(f"{key:20} {value}")Write your own FastAPI wrapper
The Agent Server gives you threads, runs, crons and Studio, but production use needs a LangSmith plan or an Enterprise licence. The alternative is to serve the graph yourself. You keep everything inside the graph and take over three jobs the server did for you: persistence, auth and streaming. Create wrapper.py in the lab folder.
Create an empty file named wrapper.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
AsyncSqliteSaver is the async SQLite checkpointer from langgraph-checkpoint-sqlite. build_agent is the same builder the Agent Server uses through make_graph.
"""Lab 15: the licence-free path. Serve the same graph from your own FastAPI app.
Run: uvicorn wrapper:app --port 8080
"""
import json
import os
import secrets
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import StreamingResponse
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from pydantic import BaseModel
from app.agent import build_agentBlock 2Settings and the request body
The API key and the database path can be overridden with environment variables. nw-dev-support is a demo key only; in production read keys from a secret store or verify a JWT. ChatRequest is the JSON body /chat expects.
API_KEYS = {os.environ.get("NORTHWIND_API_KEY", "nw-dev-support")}
SQLITE_PATH = os.environ.get(
"NORTHWIND_SQLITE", str(Path(__file__).resolve().parent / "northwind_threads.db")
)
class ChatRequest(BaseModel):
"""Body of POST /chat."""
message: str
thread_id: str = "anonymous"Block 3Persistence in the lifespan
AsyncSqliteSaver.from_conn_string(...) is an async context manager. Open it once, around the yield in the lifespan, not once per request. The graph is built on top of it and stored on app.state.
Conversations are written to northwind_threads.db. With InMemorySaver they would vanish on every restart or deploy.
def make_lifespan(model: Any = None) -> Callable[[FastAPI], Any]:
"""Open persistence once per process and build the graph on top of it."""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""SQLite on disk, so conversations survive a restart. InMemorySaver would not."""
async with AsyncSqliteSaver.from_conn_string(SQLITE_PATH) as saver:
app.state.graph = build_agent(model, checkpointer=saver)
yield
return lifespanBlock 4API-key auth
A FastAPI dependency that checks the x-api-key header. secrets.compare_digest compares in constant time, so response timing does not leak how much of a key matched. A missing or wrong key gets 401.
def make_key_check(keys: set[str]) -> Callable[..., Any]:
"""Return a FastAPI dependency that rejects requests without a valid x-api-key."""
async def require_api_key(x_api_key: str = Header(default="")) -> str:
"""Constant-time API-key check. A JWT verification would go in the same place."""
if not any(secrets.compare_digest(x_api_key, key) for key in keys):
raise HTTPException(401, "Missing or invalid x-api-key", {"WWW-Authenticate": "ApiKey"})
return x_api_key
return require_api_keyBlock 5Streaming as Server-Sent Events
graph.astream(..., stream_mode="messages") yields (chunk, metadata) tuples. Tool results flow through the same channel, so the code only forwards text produced by the model node.
Each SSE frame is event: ... plus data: ... and ends with a blank line (\n\n). Forget the blank line and the browser buffers forever. A final end event tells the client the answer is complete.
async def sse_events(graph: Any, message: str, thread_id: str) -> AsyncIterator[str]:
"""Stream the model's tokens as Server-Sent Events, checkpointed under thread_id."""
async for chunk, meta in graph.astream(
{"messages": [{"role": "user", "content": message}]},
config={"configurable": {"thread_id": thread_id}},
stream_mode="messages",
):
# Tool results stream through this channel too; send only the model's own tokens.
if chunk.text and meta["langgraph_node"] == "model":
yield f"event: token\ndata: {json.dumps({'text': chunk.text})}\n\n"
yield f"event: end\ndata: {json.dumps({'thread_id': thread_id})}\n\n"Block 6The app
GET /healthis open, so load balancers and container probes can reach it, and it names the checkpointer in use.POST /chatrequires the key and returns aStreamingResponse. TheCache-Control: no-cacheandX-Accel-Buffering: noheaders stop proxies such as nginx from buffering the stream.
app = build_app() at the bottom is what uvicorn wrapper:app loads.
def build_app(model: Any = None, keys: set[str] = API_KEYS) -> FastAPI:
"""Build the wrapper app: an open health probe and an authenticated streaming chat."""
app = FastAPI(title="Northwind support (DIY wrapper)", lifespan=make_lifespan(model))
require_api_key = make_key_check(keys)
@app.get("/health")
async def health() -> dict[str, str]:
"""Unauthenticated so probes can reach it, and specific enough to be worth reading."""
return {"status": "ok", "checkpointer": type(app.state.graph.checkpointer).__name__}
@app.post("/chat")
async def chat(body: ChatRequest, _: str = Depends(require_api_key)) -> StreamingResponse:
"""Stream the agent's answer. `no-cache` and `X-Accel-Buffering: no` stop proxy buffering."""
return StreamingResponse(
sse_events(app.state.graph, body.message, body.thread_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
return app
app = build_app()Write a client for the wrapper
A small script that talks to the wrapper the way a web page would: it checks the health probe and the auth, then streams two turns of one conversation.
Create an empty file named chat_client.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports and settings
httpx is a modern HTTP client with streaming support. The key matches the demo key in wrapper.py.
"""Lab 15: a small client for the FastAPI wrapper.
Start the wrapper first: uvicorn wrapper:app --port 8080
Full demo: python chat_client.py
One message: python chat_client.py "What did I ask you before?" --thread lab-1
"""
import argparse
import json
import httpx
BASE_URL = "http://127.0.0.1:8080"
API_KEY = "nw-dev-support"Block 2Stream one message
httpx.stream(...) keeps the response open and iter_lines() hands you each line as it arrives. The function remembers the last event: line and, for token events, prints the text from the following data: line straight away. That is exactly what a browser's EventSource does for you.
def chat(message: str, thread_id: str) -> str:
"""POST one message and print the reply token by token as the SSE frames arrive."""
reply: list[str] = []
event = ""
print(f"You ({thread_id}): {message}")
print("Agent: ", end="", flush=True)
with httpx.stream(
"POST", f"{BASE_URL}/chat", timeout=120,
headers={"x-api-key": API_KEY},
json={"message": message, "thread_id": thread_id},
) as response:
response.raise_for_status()
for line in response.iter_lines():
if line.startswith("event: "):
event = line.removeprefix("event: ")
elif line.startswith("data: ") and event == "token":
text = json.loads(line.removeprefix("data: "))["text"]
reply.append(text)
print(text, end="", flush=True)
print(f"\n({len(reply)} token frames)\n")
return "".join(reply)Block 3Health and auth checks
Two quick requests: /health without a key must succeed, /chat without a key must return 401.
def check_health_and_auth() -> None:
"""Show that /health is open and /chat refuses a request without an API key."""
health = httpx.get(f"{BASE_URL}/health", timeout=10)
print(f"GET /health -> {health.status_code} {health.json()}")
denied = httpx.post(f"{BASE_URL}/chat", json={"message": "hi"}, timeout=10)
print(f"POST /chat without key -> {denied.status_code} {denied.json()['detail']}\n")Block 4Command line
With no arguments it runs the full demo: the checks, then a question and a follow-up on the same thread_id. The follow-up only makes sense if the server remembers the first turn.
With a message argument it sends just that message, which you use to test persistence after a restart.
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Talk to the Northwind FastAPI wrapper.")
parser.add_argument("message", nargs="?", help="send just this message")
parser.add_argument("--thread", default="lab-1", help="conversation id (default: lab-1)")
args = parser.parse_args()
try:
if args.message:
chat(args.message, args.thread)
else:
check_health_and_auth()
chat("Where is order A-100?", args.thread)
chat("Which customer placed that order, and what plan are they on?", args.thread)
except httpx.ConnectError:
raise SystemExit("No wrapper on port 8080. Start it first: uvicorn wrapper:app --port 8080")Validate the configuration
In your lab folder, with the virtual environment active, check langgraph.json:
langgraph validatelanggraph validateYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
Configuration file C:\labs\lab-15-ship-it\langgraph.json is valid. (1 graph found)If you see an error instead, compare your file with the one above. JSON does not allow trailing commas or comments.
Terminal 1: start the Agent Server
Start the local Agent Server. langgraph dev runs the same API as a production Agent Server, in memory, with hot reload and no licence key. --no-browser stops it from opening LangSmith Studio in your browser.
langgraph dev --no-browserlanggraph dev --no-browserThe start of the log looks like this. More log lines follow; they are normal.
Welcome to
╦ ┌─┐┌┐┌┌─┐╔═╗┬─┐┌─┐┌─┐┬ ┬
║ ├─┤││││ ┬║ ╦├┬┘├─┤├─┘├─┤
╩═╝┴ ┴┘└┘└─┘╚═╝┴└─┴ ┴┴ ┴ ┴
- 🚀 API: http://127.0.0.1:2024
- 🎨 Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
- 📚 API Docs: http://127.0.0.1:2024/docs
This in-memory server is designed for development and testing.
For production use, please use LangSmith Deployment.After a few seconds the log shows the API address http://127.0.0.1:2024 and a Studio URL. You may see warnings about a missing LangSmith API key or tracing; they are harmless for local development.
Leave this terminal running. Open a second terminal, cd into the lab folder and activate the virtual environment there too. Stop the server later with Ctrl+C.
Optional: open the Studio URL in your browser, pick the support graph and send Where is order A-100? to watch the nodes run. Studio is a web page that talks to your local server; your graph never leaves your machine. Some browsers block this; the lab works fine without it.
Terminal 2: probe the server
Call the built-in health route and your custom route. On Windows use curl.exe (with .exe): in Windows PowerShell plain curl is an alias for Invoke-WebRequest.
curl.exe -s http://127.0.0.1:2024/ok
curl.exe -s http://127.0.0.1:2024/northwind/infocurl -s http://127.0.0.1:2024/ok; echo
curl -s http://127.0.0.1:2024/northwind/info; echoYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
{"ok":true}
{"service":"northwind-support","revision":"dev","environment":"local"}/ok is the endpoint every load balancer and probe should hit. /northwind/info comes from app/server.py: the server mounted your FastAPI app next to its own routes. Try http://127.0.0.1:2024/docs in a browser to see the generated API reference, custom routes included.
Terminal 2: run the SDK tour
python ship_it.pypython3 ship_it.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
assistant_id 9ea385c8-c1e7-42ad-a31b-712df23c9c7b
thread_id 01a0d4da-1028-7122-8bfe-35d67af14009
stream_chunks 65
background_run_id 01a0d4da-394b-7893-81c1-e7636cc3503d
cron_id 01a0d4da-5805-7172-9139-a9015adaffa7 (deleted)
messages_in_thread 10
last_reply Customer C-1 is on the enterprise plan; their ID is C-1 and region EU.What each line tells you:
- assistant_id: your versioned configuration. Run the script again and the id stays the same, thanks to
if_exists="do_nothing". - thread_id: a new UUID for each run of the script. Thread ids on the server are UUIDs; a hand-written id like
my-threadis rejected. - stream_chunks: how many message events arrived over SSE while the first answer was generated.
- background_run_id: the queued run you joined.
- messages_in_thread: both questions, the tool calls, the tool results and both answers, all persisted on the thread.
- last_reply: the background run's answer about customer C-1.
Look at terminal 1: the server logged every request. When you are done, stop it with Ctrl+C.
Terminal 1: start your FastAPI wrapper
Stop langgraph dev with Ctrl+C if it is still running. Then start your own server with uvicorn, the ASGI server FastAPI runs on. wrapper:app means the app object in wrapper.py.
uvicorn wrapper:app --port 8080uvicorn wrapper:app --port 8080You should see Application startup complete. and Uvicorn running on http://127.0.0.1:8080. A file named northwind_threads.db appears in your lab folder: the lifespan opened the SQLite checkpointer. Leave the server running.
Terminal 2: talk to the wrapper
python chat_client.pypython3 chat_client.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
GET /health -> 200 {'status': 'ok', 'checkpointer': 'AsyncSqliteSaver'}
POST /chat without key -> 401 Missing or invalid x-api-key
You (lab-1): Where is order A-100?
Agent: Order A-100 is in transit via DHL and is scheduled to arrive on 2026-09-15; the order is for customer C-1 (Acme GmbH).
(37 token frames)
You (lab-1): Which customer placed that order, and what plan are they on?
Agent: Order A-100 was placed by customer C-1, Acme GmbH, and they are on the enterprise plan.
(25 token frames)Three things to notice:
/healthanswers without a key and reportsAsyncSqliteSaver;/chatwithout a key gets401.- The answer arrives as many small token frames, printed as they come.
- The follow-up says that order and the agent still knows it means A-100. The wrapper loaded the thread
lab-1from SQLite before answering.
Terminal 2: the same calls without Python (optional)
Any HTTP client can use the wrapper. On Windows, Invoke-RestMethod waits for the whole response and then prints the raw SSE frames; on macOS and Linux, curl -N prints them as they stream.
Invoke-RestMethod http://127.0.0.1:8080/health
$body = @{ message = 'Is order A-102 delivered? One short sentence.'; thread_id = 'raw-1' } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8080/chat -Body $body -ContentType 'application/json' -Headers @{ 'x-api-key' = 'nw-dev-support' }curl -s http://127.0.0.1:8080/health; echo
curl -N -X POST http://127.0.0.1:8080/chat \
-H 'x-api-key: nw-dev-support' -H 'content-type: application/json' \
-d '{"message": "Is order A-102 delivered? One short sentence.", "thread_id": "raw-1"}'Your output should look similar to this. The model splits words into tokens, so one frame is often only part of a word.
{"status":"ok","checkpointer":"AsyncSqliteSaver"}
event: token
data: {"text": "Order"}
event: token
data: {"text": " A"}
event: token
data: {"text": "-"}
event: token
data: {"text": "102"}
event: token
data: {"text": " was"}
event: token
data: {"text": " delivered"}
event: token
data: {"text": " on"}
event: token
data: {"text": " "}
event: token
data: {"text": "202"}
event: token
data: {"text": "6"}
event: token
data: {"text": "-"}
event: token
data: {"text": "09"}
event: token
data: {"text": "-"}
event: token
data: {"text": "09"}
event: token
data: {"text": "."}
event: end
data: {"thread_id": "raw-1"}Each frame is an event: line, a data: line with JSON and a blank line. The final event: end carries the thread_id, so a web page knows the answer is complete.
Prove that conversations survive a restart
This is the claim that makes the wrapper deployable. In terminal 1 press Ctrl+C, then start the wrapper again with the same command, uvicorn wrapper:app --port 8080. In terminal 2 continue the conversation on thread lab-1:
python chat_client.py "Remind me: which order did I ask about first?" --thread lab-1python3 chat_client.py "Remind me: which order did I ask about first?" --thread lab-1The agent answers A-100, even though the server process is brand new: the history came from northwind_threads.db.
Now stop the server, delete northwind_threads.db, start it again and ask the same question. The agent no longer knows. That file is your conversation history, which is exactly why InMemorySaver is not a deployment. Stop the server with Ctrl+C when you are done.
Check your work
You are done when
langgraph validatereports the configuration as valid./okand/northwind/infoanswer whilelanggraph devis running.ship_it.pyprints an assistant id, a thread id, a positivestream_chunkscount and a deleted cron.- The wrapper answers
/healthwithout a key, returns401for/chatwithout a key, and streams many token frames with the key. - After restarting the wrapper, the agent still remembers the first question on thread
lab-1. - You can say which option you would pick for a three-person team and for a regulated enterprise, and why.
Troubleshooting
langgraph is not recognized as a command
Activate the virtual environment in this terminal. The command is installed by langgraph-cli[inmem] into .venv.
langgraph dev fails with ModuleNotFoundError: No module named 'model_setup'
Start langgraph dev from the lab folder (where langgraph.json and model_setup.py are), and keep "dependencies": ["."] in langgraph.json.
Required package 'langgraph-api' is not installed
Install the CLI with its extra: pip install "langgraph-cli[inmem]==0.4.31". On PowerShell keep the quotes.
Address already in use, or port 2024 / 8080 is busy
An old server is still running. Stop it with Ctrl+C in its terminal, or use another port: langgraph dev --port 2025 (and change SERVER_URL in ship_it.py) or uvicorn wrapper:app --port 8081 (and change BASE_URL in chat_client.py).
ship_it.py says there is no Agent Server on port 2024
Terminal 1 must still be running langgraph dev. Wait until its log shows the API address before running the script.
Warnings about LangSmith, tracing or a missing API key in the server log
Harmless for this lab. langgraph dev works without a LangSmith account; the warnings only mean no traces are uploaded.
chat_client.py fails with 401
The key sent by the client must match API_KEYS in wrapper.py. If you set NORTHWIND_API_KEY in your environment, use the same value in chat_client.py.
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
- Assistant versions. With
langgraph devrunning, callclient.assistants.update(assistant_id, config={"configurable": {"verbosity": "low"}}), list versions withclient.assistants.get_versions(...)and roll back withclient.assistants.set_latest(assistant_id, 1). Then decide: a prompt change broke production. Do you redeploy the image or change the assistant version? - Extend the wrapper. Add a non-streaming
POST /chat/syncthat returns{"thread_id", "reply"}, or an in-flight counter so/healthreports load. Keep the file under a hundred lines. - Auth on the Agent Server. The course repo's
modules/15-deployment/app/auth.pyshows@auth.authenticateplus per-resource@auth.onhandlers that give row-level multi-tenancy. Add"auth": {"path": "./app/auth.py:auth"}tolanggraph.jsonand expect401on requests withoutx-api-key. - Docker.
langgraph build -t northwind-agentbuilds an Agent Server image (production use needs a licence key). For the licence-free path, write a small Dockerfile that installsrequirements.txtand runsuvicorn wrapper:app --host 0.0.0.0 --port 8080. - Azure Container Apps. Push the wrapper image to Azure Container Registry and run it on Azure Container Apps with
az containerapp up, passing the Azure OpenAI settings as secrets. Swap SQLite for Postgres (AsyncPostgresSaver) before you run more than one replica, and delete the resource group afterwards.
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.