How I Built Voyager — A Multi-Agent Architecture Using LangGraph
- Nagesh Singh Chauhan
- Jun 23
- 8 min read
A production trip-planning system that dispatches 8 AI specialists in parallel, fuses live data from 5 APIs, and produces a complete travel itinerary in seconds — all orchestrated by a single stateful graph.

Before I continue here is the github repo of this project: Voyager
I've also recorded a video to demonstrate the application.
Why multi-agent — and why not just one big prompt?
Planning a trip is a coordination problem, not a retrieval problem. A traveller heading to Tokyo for a week needs flights, a hotel, the right neighbourhood restaurants, cultural tips, the weather forecast, visa status from their origin country, local transport options, relevant events, and a day-by-day schedule — all stitched coherently within a budget. That is not one question. It is eight questions being answered simultaneously against live data, each requiring its own tool, its own failure path, and its own output schema.
I first tried a single-chain approach: one giant prompt, one API call, one LLM pass. The results were brittle. The model hallucinated flight prices. It couldn't browse live hotel inventory. It had no idea what was happening at a venue in Shibuya next Thursday. More fundamentally, everything had to run sequentially — eight tool calls in series added latency that made the experience feel like loading a 2003 website.
The correct architecture isn't a smarter prompt. It's a system where eight specialists work in parallel, each grounded by a real API, and their results are merged by a coordinator that understands the whole picture.
That insight — parallelism as a first-class design requirement — is what led me to LangGraph, and ultimately to Voyager.

A LangGraph primer: graphs, state, and edges
LangGraph is a low-level orchestration framework built by LangChain Inc. for constructing stateful, multi-actor agent workflows. Its mental model borrows from Pregel (Google's graph processing system) and Apache Beam: nodes perform computation, edges define flow, and a centralised state object acts as shared memory throughout the entire run.
The three primitives

State as shared memory
In a monolithic LLM chain, context is passed as a string. In LangGraph, context is a typed dictionary. Every node reads from it and returns a partial update — only the keys it owns. LangGraph merges those partials into the authoritative state object. This gives you compile-time schema validation, zero data races, and a clear audit trail of which agent wrote what.

Conditional edges vs. Send()
LangGraph has two mechanisms for branching. A conditional edge evaluates the state and routes to exactly one next node — useful for supervisor patterns where a router decides which specialist to invoke. Send() is fundamentally different: it returns a list of (node_name, state) pairs, and LangGraph launches all of them simultaneously. That is the fan-out primitive that makes Voyager's 8-agent parallelism possible.
💡 Key insight: With Send(), wall-clock time equals the slowest single agent — not the sum of all agents. If each agent takes ~4 seconds, eight of them still complete in ~4 seconds total.
Architecture overview
Voyager's execution model has three phases: a sequential entry point, a parallel scatter phase, and a sequential synthesis phase. Here is the full graph topology, annotated:

The fan-in guarantee: LangGraph's merge_results barrier node does not execute until every agent in the parallel fan-out has returned its partial state update. This is built into the framework — no manual synchronisation primitives needed.
Shared state: the contract every agent signs
The most consequential design decision in any LangGraph system is the shape of the shared state. Get it wrong and you'll spend your time fighting merge conflicts, debugging stale keys, and untangling coupling between agents. Voyager uses a TypedDict called TripState as its single source of truth — a contract that every agent reads from and writes into.

The reducer pattern
The errors field uses a custom reducer because eight agents may all write to it simultaneously. Without a reducer, the last writer would win and errors from other agents would be silently dropped. The _merge_errors reducer appends new errors to existing ones, preserving the full failure log regardless of write order. This is LangGraph's answer to concurrent state mutation.

⚠️ Design rule: In parallel fan-out systems, each agent must own a unique partition of the state. If two agents ever write to the same key without a reducer, the last writer wins silently. Partition your state before you add your first agent.
Graph topology and the Send() mechanism
The entire graph is defined in trip_planner/graph.py in about 40 lines of code. That terseness is the point: the orchestration logic lives in the framework, not in bespoke threading or async code.

How Send() achieves true parallelism

Each Send() call passes the current snapshot of TripState to the target node. LangGraph's runtime launches all eight nodes concurrently — no asyncio.gather, no thread pools, no coordination code in user land. The framework manages the barrier: merge_results will not run until every Send() recipient has returned.
The 9 agents, one by one
Every Voyager agent follows the same two-track execution pattern, but each brings a distinct set of tools, a unique output schema, and domain-specific logic. Here is the complete cast.



The universal agent pattern
Every parallel agent follows a consistent two-track execution model. This is the most important structural decision in Voyager: the graph always completes, regardless of API availability.

The info_agent: a deeper look
The most sophisticated agent is info_agent. It runs a three-step pipeline inside a single node: first it geocodes the destination via OpenWeatherMap's geocoding endpoint; then it fetches the 5-day forecast in 3-hourly slots and maps each slot to a named period (Morning / Afternoon / Evening / Night) with an icon key; finally it passes the full weather corpus plus Tavily travel guide snippets to the LLM, which synthesises four distinct outputs — weather summary, packing list, food culture, and travel tips — in a single LLM call. This batching reduces latency without sacrificing structure.
merge_results: the barrier node

itinerary_agent: synthesis
The final node runs sequentially with the full aggregated state in view. It generates exactly nights day objects, distributes activities across the trip (avoiding front-loading), references specific restaurants from restaurant_results, and includes at least one downtime block. Each day has a morning / afternoon / evening breakdown with suggested times and a dining plan.
Tools & external APIs
All tool wrappers live in trip_planner/tools.py. Each function has a consistent contract: accept trip parameters, return a JSON string or error dict, and never raise — errors are captured and returned so the agent's LLM fallback can engage.

IATA resolution: a case study in robustness
The flight agent must resolve any human-readable city name to a valid IATA airport code. A naïve approach would break for "New Delhi" (DEL), "Bangalore" (BLR), or "Phuket" (HKT). Voyager uses a three-layer lookup cascade:

Budget allocation

✅ All three constants live in config.py and must sum to 1.0. Changing the budget split is a one-line configuration change — no agent code needs to be touched.
Production-grade error handling
A multi-agent system that fails when one API is down is a prototype, not a product. Voyager is designed so that the graph always reaches END, regardless of how many APIs fail simultaneously. This is achieved through three mechanisms.
1. The LLM fallback
Every agent wraps its tool call in a try/except. On any exception — rate limit, network timeout, auth error, empty response — the agent switches to LLM-only mode. Given the destination, dates, travel style, and budget, the LLM generates a plausible result set that conforms to the expected schema. Users see a complete trip plan; a warning panel notes which data sources were unavailable.

2. The is_tool_error helper
APIs don't always raise exceptions on failure — they return error payloads. The shared is_tool_error(raw) helper in agents/_base.py detects these: empty responses, error keys, HTTP status codes in JSON, and null results. It gives each agent a clean way to detect failures without duplicating detection logic.
3. The errors reducer
Failed agents append to state["errors"] via the custom reducer. After all agents complete, merge_results reads this list and prints a warnings panel. The graph continues with whatever data is available — partial information is always better than no information, and a restaurant_agent failure doesn't cancel the flight and hotel results that succeeded.
restaurant_agent's three-level fallback
Some agents have domain-specific fallback chains. restaurant_agent has three levels:
SerpAPI Google Local — preferred, rich structured data
Tavily web search — if SerpAPI is unavailable
LLM generation — if both search tools fail
🧪 Voyager's test suite includes a full-graph resilience test: all 5 external APIs are patched to raise exceptions simultaneously. The test verifies that the graph still reaches END, produces a valid daily_itinerary, and records all 8 agent failures in state["errors"].
Dashboard, CLI, and PDF export
The graph is the engine. Voyager ships three interfaces on top of it: a Panel web dashboard, an interactive CLI, and a full PDF export via FPDF2.
Panel dashboard (dashboard.py)
The web interface is built with Panel and hvplot. It presents every agent's output as a separate, navigable section — a budget donut chart, a weather timeline, hotel cards with photos, restaurant cards, flight options, a day-by-day itinerary, and a visa information panel. Users can adjust trip parameters, re-run the graph, and download the full trip plan as a PDF with one click.

CLI (cli.py)
The CLI supports both interactive prompting and one-shot flag-based invocation. After the plan is generated, it opens a post-plan chat session via trip_planner/chat.py — a rolling 10-message conversation that lets users ask follow-up questions about the trip without re-running the full graph. Typing pdf in the chat session triggers PDF export.
PDF export (pdf_export.py)
Built with FPDF2, the PDF export generates a complete, print-ready travel document: a branded cover page, weather section with forecast, flight options table, hotel cards, activities list, day-by-day itinerary, food and culture tips, packing checklist, and a final budget summary. Every section maps directly to a TripState key — making the PDF a natural projection of the graph's shared state.
LLM factory: multi-provider support

Switching providers is a single environment variable change. The get_llm() factory in trip_planner/llm.py returns a LangChain chat model regardless of provider, so all agents are provider-agnostic.
Lessons from building Voyager
What worked extremely well
LangGraph's Send() is the right primitive for IO-bound fan-out. Trip planning is almost entirely IO-bound — network calls to APIs. Send() turns eight sequential API calls (~32s) into eight concurrent calls (~4s). The graph framework handles the synchronisation, leaving agent code clean and single-purpose.
Strict state partitioning prevents every class of concurrent-write bug. Because each agent owns exactly one set of state keys and those sets don't overlap (except errors, which has a reducer), there are zero race conditions in userland. The framework's merge is deterministic.
The two-track resilience pattern is worth the boilerplate. In integration testing with real APIs, at least one API was unavailable in roughly 20% of runs. Without fallbacks, 20% of trip plans would have failed entirely. With the LLM fallback, 100% of runs produced a complete plan — with a note about which data was estimated.
What I'd do differently
TypedDict vs. Pydantic for state. TypedDict gives no runtime validation. An agent returning the wrong type for flight_results will fail silently at read time rather than write time. Pydantic v2 models would catch this at the reducer boundary. For a production system I would migrate the state schema to Pydantic.
LangSmith tracing from day one. Debugging a parallel agent system without traces is like debugging a distributed system without logs — painful. LangSmith's tracing shows the exact state at each node, the LLM calls each agent made, and which agents ran concurrently. I added it mid-project and wished I'd started with it.
Sub-graph for the info_agent pipeline. The info_agent runs a 3-step pipeline (geocode → forecast → LLM synthesis) inside a single node. A cleaner design would express this as a sub-graph with its own nodes and edges, making each step individually retryable and observable.
Extending Voyager
Adding a new parallel agent takes exactly 6 steps and 6 lines of framework code:

The best measure of an architecture is how easy it is to extend without breaking what already works. Voyager's answer: 6 lines and 6 steps, and all existing agents are untouched.
The future roadmap

Closing thoughts
Voyager started as a question: what does a production multi-agent system actually look like in 2026, beyond the toy examples? The answer, at least for IO-bound parallel workloads, is cleaner than I expected. LangGraph's StateGraph, Send(), and reducer primitives handle the hard parts — concurrency, state merging, barrier synchronisation — and leave you to focus on the agents themselves.
The trip planner domain turned out to be an ideal test bed: rich enough to require genuine specialisation across agents, grounded enough to demand live data, and user-facing enough to care about latency and resilience. If you're exploring multi-agent architectures, I'd encourage you to start with a real domain problem rather than a generic assistant — the constraints are what make the design decisions interesting.




Comments