Project: multi-agent travel planner
Before we begin
Build an interview-ready demo: user asks for a trip plan; a planner agent drafts steps; an executor agent calls weather and maps/geocoding APIs; memory stores preferences; the UI shows reasoning steps.
Figure
Project architecture
How this connects to Module 8
| Lesson | Where you use it |
|---|---|
| Agents vs chains | Planner proposes; executor grounds with tools |
| Tool calling | get_weather, geocode_city JSON schemas |
| Memory | User prefs injected into planner system prompt |
| ReAct / loops | Observe tool result → replan on failure |
| Guardrails | Max retries, iteration cap, read-only tools |
Folder layout:
travel-planner/
agents/
planner.py
executor.py
graph.py # LangGraph or manual loop
tools/
weather.py
geocode.py
memory.py
server/
app.py # FastAPI or Flask
app/
api/travel-plan/route.ts
travel-lab/page.tsxWhat you will build
- Planner agent — itinerary outline from user request + stored prefs.
- Executor agent —
get_weather,geocode_city, optionaldistance_estimatetools. - Memory — MongoDB or JSON file (
preferredClimate,homeAirport, past destinations). - Retry logic — bad city name, API timeout → replan (max 3).
- Next.js UI — chat + expandable reasoning trace (thoughts, tool calls, observations).
Estimated time: 5–8 hours.
Before you start
- Finish Module 8 quiz.
- LLM API with tool calling.
- OpenWeatherMap API key (or similar).
- Optional: OpenRouteService / Google Maps for distances.
pip install langgraph langchain-openai pymongo python-dotenv requests
Step 1 — Define tools (executor)
Goal: Small, testable functions the LLM can call — each returns structured JSON.
# tools/weather.py
import os
import requests
def get_weather(lat: float, lon: float) -> dict:
key = os.environ["OPENWEATHER_API_KEY"]
url = "https://api.openweathermap.org/data/2.5/weather"
r = requests.get(url, params={"lat": lat, "lon": lon, "appid": key, "units": "metric"}, timeout=10)
r.raise_for_status()
data = r.json()
return {
"temp_c": data["main"]["temp"],
"description": data["weather"][0]["description"],
"humidity": data["main"]["humidity"],
}# tools/geocode.py
def geocode_city(city: str) -> dict:
key = os.environ["OPENWEATHER_API_KEY"]
url = "https://api.openweathermap.org/geo/1.0/direct"
r = requests.get(url, params={"q": city, "limit": 1, "appid": key}, timeout=10)
r.raise_for_status()
hits = r.json()
if not hits:
return {"error": f"city not found: {city}"}
h = hits[0]
return {"city": h["name"], "country": h.get("country"), "lat": h["lat"], "lon": h["lon"]}Tool schemas for the LLM:
TOOLS = [
{
"type": "function",
"function": {
"name": "geocode_city",
"description": "Resolve a city name to latitude and longitude",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather for coordinates",
"parameters": {
"type": "object",
"properties": {
"lat": {"type": "number"},
"lon": {"type": "number"},
},
"required": ["lat", "lon"],
},
},
},
]Dispatch table:
def run_tool(name: str, args: dict) -> dict:
if name == "geocode_city":
return geocode_city(args["city"])
if name == "get_weather":
return get_weather(args["lat"], args["lon"])
return {"error": f"unknown tool {name}"}Step 2 — Memory layer
# tools/memory.py
import json
from pathlib import Path
STORE = Path("data/user_prefs.json")
def load_user_prefs(user_id: str) -> dict:
if not STORE.exists():
return {}
all_users = json.loads(STORE.read_text())
return all_users.get(user_id, {})
def save_preference(user_id: str, key: str, value: str) -> str:
all_users = json.loads(STORE.read_text()) if STORE.exists() else {}
prefs = all_users.setdefault(user_id, {})
prefs[key] = value
STORE.parent.mkdir(exist_ok=True)
STORE.write_text(json.dumps(all_users, indent=2))
return f"saved {key}={value}"
def append_trip_history(user_id: str, summary: dict):
prefs = load_user_prefs(user_id)
history = prefs.setdefault("past_trips", [])
history.append(summary)
save_preference(user_id, "past_trips", json.dumps(history)) # or dedicated fnInject into planner system message:
User prefers warm destinations, avoids red-eye flights.
Home airport: BLR.
Past trips: Rome (liked), Helsinki (too cold).Step 3 — Planner agent
Goal: Produce structured itinerary without inventing weather — executor will fetch facts.
# agents/planner.py
PLANNER_SYSTEM = """You are a travel planner.
Output valid JSON only:
{"days": [{"date": "YYYY-MM-DD", "city": "...", "activities": ["..."]}], "notes": "..."}
Use user preferences when provided.
Do NOT invent weather or coordinates — the executor will fetch them."""
def plan_trip(client, user_message: str, prefs: dict) -> dict:
messages = [
{"role": "system", "content": PLANNER_SYSTEM + "\nPrefs: " + str(prefs)},
{"role": "user", "content": user_message},
]
resp = client.chat.completions.create(model="gpt-4o-mini", messages=messages, temperature=0.4)
import json
return json.loads(resp.choices[0].message.content)Step 4 — Executor loop with LangGraph
# agents/graph.py
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, END
class TripState(TypedDict):
plan: dict
step_index: int
observations: list
trace: list
retries: int
status: Literal["running", "done", "failed"]
def execute_node(state: TripState):
day = state["plan"]["days"][state["step_index"]]
city = day["city"]
trace = state["trace"]
geo = run_tool("geocode_city", {"city": city})
trace.append({"type": "tool", "name": "geocode_city", "args": {"city": city}})
trace.append({"type": "observation", "text": str(geo)})
if "error" in geo:
return {**state, "retries": state["retries"] + 1, "trace": trace}
weather = run_tool("get_weather", {"lat": geo["lat"], "lon": geo["lon"]})
trace.append({"type": "tool", "name": "get_weather", "args": geo})
trace.append({"type": "observation", "text": str(weather)})
observations = state["observations"] + [{"city": city, "geo": geo, "weather": weather}]
return {
**state,
"step_index": state["step_index"] + 1,
"observations": observations,
"trace": trace,
"status": "done" if state["step_index"] + 1 >= len(state["plan"]["days"]) else "running",
}
def should_continue(state: TripState):
if state["status"] == "done":
return END
if state["retries"] > 3:
return "fail"
return "execute"
graph = StateGraph(TripState)
graph.add_node("execute", execute_node)
graph.set_entry_point("execute")
graph.add_conditional_edges("execute", should_continue, {"execute": "execute", "fail": END, END: END})
app = graph.compile()On geocode failure, increment retries and optionally call planner again with error context to fix city spelling.
Step 5 — Next.js API + UI
// app/api/travel-plan/route.ts
export async function POST(req: Request) {
const { userId, message } = await req.json();
const res = await fetch(process.env.PY_TRAVEL_URL ?? "http://127.0.0.1:5010/plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, message }),
});
return Response.json(await res.json());
}Response shape:
{
"itinerary": "Day 1 Rome — 18°C cloudy…",
"trace": [
{"type": "thought", "text": "Fetching Rome weather"},
{"type": "tool", "name": "get_weather", "args": {"city": "Rome"}},
{"type": "observation", "text": "18°C cloudy"}
]
}UI (travel-lab/page.tsx):
- Chat input for trip request.
- Final itinerary markdown block.
- Collapsible trace timeline — icons for thought / tool / observation.
- Highlight retry steps when user deliberately misspells a city.
Step 6 — Real APIs checklist
- Weather from live API, shown in final plan
- Geocoding resolves city → coordinates
- At least one retry shown in trace after deliberate typo (
"Rom") - Preferences persist across two sessions (same
userId)
Step 7 — Safety
- Read-only tools for v1 — no real bookings or payments.
- Rate-limit
/api/travel-plan(30/min). - Cap iterations — e.g. 10 tool calls max per request.
- Never log full API keys; use
.env.local.
Troubleshooting
| Symptom | Fix |
|---|---|
| Planner returns prose not JSON | Add response_format={"type":"json_object"} or stricter prompt |
city not found loop | Cap retries; ask user to clarify spelling |
| Empty trace in UI | Return trace array from Python; map types in React |
| 401 from weather API | Check OPENWEATHER_API_KEY in env |
Deliverables
- Planner + executor separation documented in README
- Trace visible in UI
- Memory read/write working
- README with architecture diagram + demo screencap
What's next
Module 8 complete. Continue to Module 9 — Multimodal & image models, then Module 10 — Production & scaling for the course capstone.
Return to the AI course curriculum anytime to track progress.