Lab 05 · Live console
Build a live terminal view of an agent run: tool calls, progress from a slow tool and the answer token by token, all from one stream call.
What you will build
The terminal view a support engineer would watch while the Northwind agent works. As the run happens, it shows which tools the model calls, progress updates from a slow carrier lookup, each tool result, and finally the answer appearing word by word.
Everything comes from one agent.stream(...) call that listens to three channels at once.
What you will learn
- What the
updates,messagesandcustomstream modes each deliver - How to subscribe to several stream modes at once with
version="v2" - How a tool sends its own progress events with
get_stream_writer() - How to print answer tokens as they arrive, and only from the model node
- Why you must render inside the loop instead of collecting the stream first
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-05-live-console
cd C:\labs\lab-05-live-console
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-05-live-console
cd ~/labs/lab-05-live-console
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:
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.11langgraph provides get_stream_writer, which the slow tool uses to send progress events.
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.
Write the agent helper
First a small helper file with the agent: one fast tool, one slow tool and the question to ask. Keeping it separate means live_console.py is only about streaming. Both files sit in the same folder, so the console can simply import northwind.
Create an empty file named northwind.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
get_stream_writer is the new piece: it lets code running inside the agent (here, a tool) send its own events to whoever is streaming the run.
"""Lab 05 helper: the Northwind agent with one fast tool and one slow one.
The console imports this, so live_console.py is only about streaming.
"""
import time
from typing import Any
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_core.language_models import BaseChatModel
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.config import get_stream_writer
from model_setup import get_modelBlock 2The question
The input for the run: one user message asking where order A-100 is right now.
INPUTS = {"messages": [{"role": "user", "content": "Where is order A-100 right now?"}]}Block 3Fast tool: the warehouse record
Returns the recorded status and ETA instantly. The docstring says it does not know where the parcel physically is, which nudges the model to also call the carrier tool for a right now question.
@tool
def get_order_status(order_id: str) -> str:
"""Look up the recorded status and ETA of a Northwind order, such as A-100.
This is the warehouse record. It does not know where the parcel physically is.
"""
return f"Order {order_id} is shipped via DHL; ETA 2026-09-15."Block 4Slow tool: ask the carrier
This tool takes over a second, like a real carrier API. While it works, it reports progress:
get_stream_writer()returns a function that sends any value to thecustomstream channel.- Each
writer({...})call is delivered to the console immediately, while the tool is still running. Without it the user would stare at a frozen screen.
@tool
def check_shipment(order_id: str) -> str:
"""Ask the carrier where a parcel physically is right now. Slow: takes a few seconds."""
writer = get_stream_writer()
for pct, message in [(10, "contacting DHL"), (60, "reading scans"), (100, "done")]:
writer({"type": "progress", "tool": "check_shipment", "pct": pct, "message": message})
time.sleep(0.4)
return f"Shipment {order_id}: out for delivery in Berlin (last scan 06:12)."Block 5Build the agent
A normal create_agent with both tools. There is nothing streaming-specific here: any agent can be streamed.
def build_agent(
model: BaseChatModel | None = None, checkpointer: BaseCheckpointSaver | None = None
) -> Any:
"""Build the Northwind agent. Pass a model to override the Azure OpenAI default."""
return create_agent(
model=model or get_model(),
tools=[get_order_status, check_shipment],
system_prompt="You are the Northwind support assistant. Be brief. Always use a tool.",
checkpointer=checkpointer,
)Write the live console
agent.stream(...) returns a generator that yields events while the agent runs. You choose which kinds of events you want with stream_mode:
updates: the state change after each step, for example the model's finished message with its tool calls.messages: message pieces as they are produced, including answer tokens and tool results.custom: anything your own code sends withget_stream_writer().
Each helper below renders one channel. run_console reads the stream once and sends each event to the right helper.
Create an empty file named live_console.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
ToolMessage identifies tool results on the messages channel. INPUTS and build_agent come from the helper you just wrote.
"""Lab 05: a live console for the Northwind support agent.
One `agent.stream(...)` call feeds three channels: tool announcements from `updates`,
progress from `custom`, and the answer from `messages`, token by token.
Run: python live_console.py
"""
from typing import Any
from langchain.messages import ToolMessage
from northwind import INPUTS, build_agentBlock 2The stream modes
The three channels this console listens to. Passing a list of modes gives you all of them in one stream, interleaved in the order they happen.
STREAM_MODES = ["updates", "messages", "custom"]Block 3Print and keep a line
Every trace line is printed right away and collected, so run_console can return the full transcript (useful for tests). flush=True makes the terminal show the line at once.
def emit(line: str, lines: list[str]) -> None:
"""Print a trace line right away and keep it for the returned transcript."""
print(line, flush=True)
lines.append(line)Block 4Channel 1: announce tool calls
An updates event is a dict keyed by the node that just finished. When the model node finishes, its last message is complete, so tool_calls holds real names and arguments.
Why not use the messages channel for this? There, tool calls arrive as half-built tool_call_chunks, with arguments still being typed out.
def show_tool_calls(data: dict[str, Any], lines: list[str]) -> None:
"""Announce the tools the model just asked for (from the `updates` channel)."""
# The message is complete here, so the arguments are real JSON. The `messages`
# chunks carry half-parsed `tool_call_chunks` instead.
if "model" in data:
for call in data["model"]["messages"][-1].tool_calls:
emit(f"[tool] {call['name']}({call['args']})", lines)Block 5Channel 2: progress
custom events arrive exactly as the tool wrote them: the dict with tool, pct and message. :>3 right-aligns the percentage.
def show_progress(data: dict[str, Any], lines: list[str]) -> None:
"""Render a progress event the slow tool wrote (from the `custom` channel)."""
emit(f"[..] {data['pct']:>3}% {data['tool']}: {data['message']}", lines)Block 6Channel 3: tool results and answer tokens
A messages event is a tuple (message, metadata), not a message.
- A
ToolMessageis a finished tool result, printed as a[done]line. - Anything else is a chunk of model output.
metadata["langgraph_node"] == "model"keeps only the agent's own model tokens. Each token is printed withend=""so the answer grows on one line. - Use
message.text, notmessage.content: streamed chunks can hold a list of content blocks, and.textalways gives a plain string. Tool-call chunks have empty text, so they are skipped.
def show_message(data: tuple[Any, dict[str, Any]], lines: list[str], answer: list[str]) -> None:
"""Render a tool result, or print one answer token inline (from the `messages` channel)."""
message, metadata = data
if isinstance(message, ToolMessage):
emit(f"[done] {message.name} -> {message.text}", lines)
elif metadata["langgraph_node"] == "model" and message.text:
if not answer:
print("[answer] ", end="", flush=True)
answer.append(message.text)
print(message.text, end="", flush=True)Block 7One stream feeds everything
agent.stream(INPUTS, stream_mode=STREAM_MODES, version="v2") runs the agent. With version="v2" every event is a dict with a type (the channel) and data.
The loop renders each event as it arrives. If you wrote list(agent.stream(...)) first, you would wait for the whole run and then print everything at once: the console would no longer be live.
At the end the answer tokens are joined into one [answer] line for the transcript.
def run_console(agent: Any) -> list[str]:
"""Render one streamed run to the terminal and return the lines it wrote."""
lines: list[str] = []
answer: list[str] = []
for part in agent.stream(INPUTS, stream_mode=STREAM_MODES, version="v2"):
kind, data = part["type"], part["data"]
if kind == "updates":
show_tool_calls(data, lines)
elif kind == "custom":
show_progress(data, lines)
elif kind == "messages":
show_message(data, lines, answer)
print()
lines.append("[answer] " + "".join(answer).strip())
return linesBlock 8Run it
Builds the agent, streams one run and reports how many transcript lines run_console returned.
if __name__ == "__main__":
transcript = run_console(build_agent())
print(f"\n({len(transcript)} transcript lines returned)")Watch the live console
python live_console.pypython3 live_console.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
[tool] get_order_status({'order_id': 'A-100'})
[tool] check_shipment({'order_id': 'A-100'})
[done] get_order_status -> Order A-100 is shipped via DHL; ETA 2026-09-15.
[..] 10% check_shipment: contacting DHL
[..] 60% check_shipment: reading scans
[..] 100% check_shipment: done
[done] check_shipment -> Shipment A-100: out for delivery in Berlin (last scan 06:12).
[answer] Order A-100 — warehouse: shipped via DHL (ETA 2026-09-15). Carrier (physical location): out for delivery in Berlin; last scan 06:12. Want me to check again later?
(8 transcript lines returned)Watch the order in which lines appear, not only the final text:
- Each
[tool]line appears as soon as the model decides to call that tool. The model may call both tools at once, so both[tool]lines can come first. - The
[..]progress lines appear one by one whilecheck_shipmentsleeps. [done]lines show each tool result.- The
[answer]line grows word by word in your terminal. The expected output below only shows the finished line.
Check your work
You are done when
[tool]lines appear forget_order_statusandcheck_shipment.- The three progress lines appear one by one while the slow tool runs, after its
[tool]line. - The answer appears word by word, on a single
[answer]line. - The whole view comes from one
agent.stream(...)call.
Troubleshooting
The [tool] lines never appear
Check that you read the model node in updates. The tools node's update holds the tool results, not the calls.
Progress lines appear only after the tool finished
Something buffered the stream, for example list(agent.stream(...)). Render inside the loop.
TypeError: tuple indices must be integers
On the messages channel data is a tuple (message, metadata). Unpack it first.
The answer line is empty
Use message.text, not message.content, and check the node name is exactly "model".
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
- Measure time to first token: record
time.perf_counter()before the loop and at the first answer token, and print both that and the total time. - Swap the
printcalls for a RichLivepanel, with the trace on top and the answer growing below. The stream loop should not change. - See the same run in a browser:
pip install "langgraph-cli[inmem]", add alanggraph.jsonthat points the graph idsupportat./northwind.py:build_agent, runlanggraph dev, and open Agent Chat UI with Deployment URLhttp://localhost:2024and Graph IDsupport. Tool calls show as cards and the answer streams in.
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.