learn-langchain-with-phoebe / Builder session 3 of 10
Learn LangChain with Phoebe · Builder track · Session 3 of 10

create_agent, properly

You have used create_agent twice without looking inside. Tonight we open it: the b1 while-loop industrialized on the LangGraph runtime, a system prompt that works as DataDesk's constitution, three tools the model chooses between on its own, and streaming that makes the whole thing feel alive.

🟡 Builder track Practitioners: DA · DE · DS · engineers DataDesk v0.2 from b2 · both engines ready 45 minutes
0-3 · Recap 3-18 · Concepts: harness + streaming 18-42 · Build-along: DataDesk v1 42-45 · Q&A
Part 0

Where DataDesk stands

v0.2 can X-ray its own messages and parse messy asks into typed forms. But it still has one tool and a one-line system prompt, and it answers in a single silent blob. Tonight it becomes v1: multi-tool, properly governed, streaming. This is the last session on the high-level API before b4 adds the production layer - so we make sure you know exactly what the high-level API is doing for you.

Live - presented in session Self-study - read after class ★ Try it now prompt Official docs + Academy covered
★ What you walk out with today A mental X-ray of create_agent (model node ↔ tool node on LangGraph rails), a system prompt you can defend in review, DataDesk v1 chaining two tools on its own judgment, and token-by-token streaming on both engines.
Part 1 · covers the agents / create_agent docs

Inside the harness 9 min live

create_agent is not magic and it is not a black box. It is your b1 while-loop rebuilt as two nodes on the LangGraph runtime - and that one implementation choice is why checkpoints, streaming and human-in-the-loop show up later without rewrites.

LangGraph runtime rails model node decides: answer or tool? tool node runs the requested tool tool_call result exit: no tool requested → final answer free, from the rails · checkpoints (b6) · streaming (tonight) · HITL surfaces (b7) · time travel (b7) none of it needs a rewrite of your agent Your b1 while-loop, industrialized: same two beats, now running on rails you can eject down to.
🔍 Click to zoom - create_agent internals: model node ↔ tool node on runtime rails
LiveWhat create_agent actually runs3 min

Line up the b1 scratch loop against the diagram and the mapping is one-to-one:

  • Your while-loop body → the model node: call the model with the message stack, inspect the reply for tool calls.
  • Your "run the function, append the result" branch → the tool node: execute, wrap the result as a tool message, hand back.
  • Your break statement → the exit edge: no tool requested means the answer is final.

The difference is where it runs. Your loop ran on bare Python; this loop runs on the LangGraph runtime, which is why persistence, streaming, interrupts and time travel are configuration rather than construction. You paid one abstraction layer and bought four production features - that trade is the honest pitch, and now you can audit it.

Vocabulary that saves confusion "Agent = model + harness." The model decides; the harness (loop, tools, state, rails) is everything around the decision. create_agent is a harness factory. When people argue about "agent quality" they are usually arguing about harness quality.
LiveThe system prompt is DataDesk's constitution3 min

The system_prompt parameter is the highest-leverage string in your codebase: it rides at the top of the stack on every single turn. For an agent, it needs three sections a chat prompt does not:

  • Role: who it is and for whom. "You are DataDesk, a data team assistant" scopes the persona and the tone.
  • Tool guidance: when to reach for which tool, and what to do first. "Use list_datasets before guessing a path" prevents an entire class of silent failures.
  • Refusal rules: what it must NOT do. "If the data cannot answer, say 'not in the data' - never estimate" is the line between an assistant and a liability in front of a stakeholder.
★ Try it now (any chat AI)Here is my agent's system prompt: [paste yours]. Attack it: give me 3 user messages that would make an agent with this prompt do something embarrassing in front of a data-literate stakeholder. Then propose the minimal edit that blocks each.
Real world

The agent that invented a column. A team's assistant was asked for "churn by region" on a dataset with no region column. Without a refusal rule it averaged something plausible and presented it confidently. One sentence in the system prompt - cite your tool, or say "not in the data" - turned the same failure into a trust-building answer.

Self-studyWhere the defaults end and LangGraph begins2 min read

create_agent's defaults carry you far: single agent, flat tool list, linear conversation, middleware for cross-cutting concerns (b4). You eject down to raw LangGraph (b5) when the SHAPE of the flow itself must change:

Stay on create_agent, or eject to LangGraph? Does the SHAPE change? STAY ON create_agent tools, prompt, middleware EJECT TO LANGGRAPH custom routing, multi-agent no yes Ejecting is a rewrite of the wiring, not the parts - tools and prompts carry over.
🔍 Click to zoom - eject only when the SHAPE of the flow itself must change
NeedStay on create_agentEject to LangGraph
More tools, better promptYes - just configuration
PII scrubbing, retries, summarizationYes - middleware, b4
Branching flows, custom routingYes - you own the graph, b5
Multi-agent teams, map-reduceYes - b9

Because create_agent already runs ON LangGraph, ejecting is a rewrite of the wiring, not the parts: your tools, prompt and middleware all carry over. That is the payoff of the b1 stack diagram.

Part 2 · covers the streaming docs

Streaming and the user experience 6 min live

A 20-second silent wait reads as broken; the same 20 seconds with visible progress reads as thinking. Streaming is not decoration - for agents that use tools, it is the difference between users trusting the system and users killing the tab.

Liveinvoke vs stream - and the two modes that matter4 min

invoke blocks until everything - tool calls included - is done, then returns the full result. stream yields as work happens, and the stream_mode argument picks what "work" means:

Two stream modes, two different jobs stream_mode="messages" Tokens as the model generates them Typing effect: chat UIs, CLIs stream_mode="updates" One update per agent step fired Progress panes, logs, debugging Humans watching the answer want messages; humans watching the agent want updates.
🔍 Click to zoom - humans watching the answer want messages, humans watching the agent want updates
ModeYou receiveBuild with it
stream_mode="messages"Message chunks - tokens as the model generates themThe typing effect: chat UIs, CLIs, anything human-facing
stream_mode="updates"One update per agent step - model node fired, tool node fired, with payloadsProgress panes: "calling csv_stats...", logs, debugging tool choice
  • Rule of thumb: humans watching the answer want messages; humans (or logs) watching the AGENT want updates. Production UIs often consume both.
  • It is the rails again: streaming comes from the LangGraph runtime underneath - the same events will keep working unchanged when you eject to raw graphs in b5.
Perceived latency is the metric Time-to-first-token, not time-to-full-answer, is what users judge. Streaming does not make the agent faster - it makes the wait legible. For tool-using agents where the model pauses mid-answer, the updates narration covers exactly the seconds the token stream goes quiet.
Real world

The support-bot team that shipped the progress pane first. A team instrumenting a tool-heavy agent found users abandoned sessions during tool calls, not during generation. They shipped the updates-mode progress pane a sprint before the polished typing effect - abandonment dropped, and the typing effect became a nice-to-have instead of a rescue mission.

Self-studyMulti-tool agents - how the model chooses3 min read

With three tools bound, nothing routes but the model's own reading of your tool descriptions against the user's question. There is no dispatcher to configure - the descriptions ARE the router.

  • Name + docstring do the work: column_mean with "Mean of a numeric column in a CSV file" gets chosen for "average order value". A vague description sends the model to the wrong tool, and no amount of model quality fully compensates.
  • Overlap is the enemy: if two tools plausibly answer the same question, the model will be inconsistent between runs. Sharpen the boundary in the docstrings ("use X for single columns, Y for whole-file stats") before reaching for anything fancier.
  • Chaining is free: the loop just keeps going - tool result comes back, the model decides it needs a second tool, the loop runs again. You never wrote "first call list_datasets, then column_mean"; the model plans that. Demo 1 shows it live.
  • When descriptions stop being enough (dozens of tools, hard business rules about routing), that is a graph-shape problem: conditional edges in b5, supervisor patterns in b9.
Demo 1 of 2

DataDesk v1 ★ 12 min · everyone builds

Three tools, a constitution-grade system prompt, and a question that forces the model to chain two tools on its own judgment. This file is the trunk every later session grows from.

Add two tools next to csv_stats: column_mean(path, column) and a list_datasets() stub returning a dict of the CSVs DataDesk may touch. The stub matters - it is the catalog seam where a real metadata store plugs in later.

Replace the one-liner system prompt with the constitution below: role, tool guidance, refusal rules. Read it out loud - if a sentence would embarrass you in design review, fix it now.

Ask the two-tool question: "What is the average order value in the orders data, and how many rows is that over?" - it cannot be answered without list_datasets (or csv_stats) AND column_mean.

Watch the loop decide: which tool fired first? Did it cite the tools in the answer, as instructed? Nothing you wrote sequenced those calls - the model planned the chain.

Stress the refusal rule: ask for "churn by region". There is no such column - v1 must answer "not in the data", not improvise. If it improvises, tighten the prompt and re-run.

★ datadesk_v1.py - the whole filefrom langchain.agents import create_agent from langchain.chat_models import init_chat_model from langchain.tools import tool @tool def csv_stats(path: str) -> dict: """Row count, columns and date range of a CSV file.""" ... # unchanged from b1 @tool def column_mean(path: str, column: str) -> float: """Mean of a single numeric column in a CSV file.""" ... # ~3 lines of pandas @tool def list_datasets() -> dict: """Datasets DataDesk can access, as name -> CSV path.""" return {"orders": "data/orders.csv", "signups": "data/signups.csv"} SYSTEM = """You are DataDesk, a data team assistant. Be precise. Cite which tool produced every number you report. Call list_datasets first when the user names a dataset loosely. If the data cannot answer, say "not in the data" - never estimate.""" model = init_chat_model("claude-haiku-4-5-20251001") # or "ollama:llama3.1" agent = create_agent(model=model, tools=[csv_stats, column_mean, list_datasets], system_prompt=SYSTEM) out = agent.invoke({"messages": [{"role": "user", "content": "What is the average order value in the orders data, " "and how many rows is that over?"}]}) print(out["messages"][-1].content)
If the model only calls one tool Usually a docstring problem, not a model problem. Check that column_mean's description says "single numeric column" - vague descriptions make the model try to do everything with csv_stats.
Demo 2 of 2

Make it feel alive ★ 10 min · build your own

Same v1 file, three changed lines. First tokens as they generate, then the agent's own heartbeat - tool calls visible as they happen - and finally the engine-swap test to prove the streaming code is engine-agnostic.

Swap invoke for stream with stream_mode="messages" and print tokens as they arrive. Run the v1 question again - the typing effect appears, tool pauses and all.

Now run the same call with stream_mode="updates". Each yielded update is one agent step: watch the model node hand off to the tool node and back - your Part 1 diagram, printed live.

Narrate what a user would see in each mode: messages is the answer being typed; updates is "DataDesk is checking list_datasets...". Decide which your future UI needs. (Often: both.)

Engine-swap test: flip the model line to "ollama:llama3.1" and re-run both modes. Streaming code untouched. Local tokens arrive at your GPU's pace - feel the latency difference you would be shipping.

★ The three changed linesquestion = {"messages": [{"role": "user", "content": "What is the average order value in the orders data?"}]} # Mode 1: tokens as they generate - the typing effect for token, meta in agent.stream(question, stream_mode="messages"): print(token.text, end="", flush=True) # Mode 2: one update per agent step - the agent's heartbeat for update in agent.stream(question, stream_mode="updates"): print("STEP:", list(update.keys())) # model → tools → model
Real world

The demo that failed silently and the one that did not. Two teams demoed agents to the same executive. Team A's answer took 25 silent seconds; the exec reached for their phone and the moment died. Team B streamed the tool narration - "checking the orders dataset... computing the mean..." - and the exec leaned in and asked what else it could check. Identical model, identical latency. The stream made the wait legible.

Homework

Try it yourself - this week ◐ 30-45 min total

Source material

Official sources covered

This track teaches from the official docs and the free LangChain Academy curricula (login required for lesson content; certificates stay with the Academy - all free). This page covers:

LangChain docs: agents / create_agentPart 1 · the harness internals, system_prompt, multi-tool binding
LangChain docs: streamingPart 2 + Demo 2 · messages vs updates modes on the runtime rails
Academy: Intro to LangChain (M1 project)DataDesk v1 is the module's agent project, on our own domain and both engines
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What is create_agent actually running under the hood?

Same two beats as your scratch loop, running on rails. The rails are what you actually bought - and they are why b5's eject-to-LangGraph is a rewiring, not a rewrite.

2 · With three tools bound, what decides which tool handles a question?

No dispatcher exists to configure. Sharp, non-overlapping docstrings are the routing layer - which is why "gets stats" is a production incident waiting to happen.

3 · Your UI needs a "DataDesk is calling csv_stats..." progress pane. Which streaming mode feeds it?

messages streams the answer's tokens; updates streams the agent's steps. Humans watching the answer want messages; humans watching the agent want updates.

Builder session 3 cheat sheet · pin this

The harnesscreate_agent = model node ↔ tool node on LangGraph rails. Your b1 loop, industrialized. Agent = model + harness.
Free from the railsCheckpoints (b6), streaming (now), HITL (b7), time travel (b7) - configuration, not construction.
Constitution formatsystem_prompt = role + tool guidance + refusal rules. "Not in the data" beats a confident guess, every time.
Descriptions routeThe model picks tools by docstring. Overlapping descriptions = inconsistent routing. Sharpen the boundary first.
Streaming modesmessages = tokens (typing effect) · updates = agent steps (progress pane). UIs often consume both.
Eject lineConfig change → stay on create_agent. Flow-shape change (branching, multi-agent) → LangGraph, b5.
Engine swapinit_chat_model("claude-haiku-4-5-20251001") ↔ init_chat_model("ollama:llama3.1") - streaming code unchanged.
Running projectDataDesk v1: three tools, constitution, streaming. Next: b4 wraps it in middleware armor.