Trip Planner - Multi-Agent Travel Planning System
July 4, 2026 ยท View on GitHub
An orchestrator agent decomposes a natural language trip request and delegates to three
specialist agents (destination knowledge, activities, logistics) over the A2A protocol with
SSE streaming. Every external capability (RAG retrieval, weather, flights and hotels) lives
in its own FastMCP server, so agents own reasoning while MCP servers own tools. Long-term
traveler memory is a dedicated Mem0 service backed by Qdrant, written only by the
orchestrator and queried by every agent scoped to the user id. Each agent is a compiled
LangGraph graph with a Postgres checkpointer, and OpenTelemetry trace context is propagated
through A2A metadata so one user request appears as one nested Langfuse trace. The whole
stack, including the self-hosted Langfuse v3 platform, starts with a single
docker compose up --build.

Architecture
flowchart TB
Browser["๐ฅ๏ธ Web UI or any AG-UI client"]
subgraph ORCH["Orchestration"]
Orchestrator["๐ง orchestrator :8000<br/>FastAPI + LangGraph supervisor<br/>A2A client, owns Mem0 writes"]
end
subgraph AGENTS["Specialist agents, A2A servers with LangGraph"]
Destination["๐ destination-agent :8101<br/>destination knowledge, RAG only"]
Activities["๐๏ธ activities-agent :8102<br/>weather-aware day planning"]
Logistics["โ๏ธ logistics-agent :8103<br/>flights and hotels"]
end
subgraph MCPS["MCP servers, FastMCP streamable HTTP"]
Rag["๐ rag-mcp :8201<br/>kb_search, kb_ingest"]
Weather["๐ฆ๏ธ weather-mcp :8202<br/>weather_forecast"]
Travel["๐จ travel-mcp :8203<br/>flights_search, hotels_search"]
end
subgraph MEM["Long-term memory"]
Mem0["๐งพ mem0 :8300<br/>REST wrapper around mem0 OSS"]
Qdrant["๐งฒ qdrant :6333<br/>vector store"]
end
Postgres["๐ postgres + pgvector :5432<br/>LangGraph checkpoints, kb chunks"]
Seed["๐ data/seed<br/>city guides, flight and hotel fixtures"]
Langfuse["๐ญ Langfuse :3000<br/>one nested trace per request"]
OpenMeteo["โ๏ธ Open-Meteo<br/>external forecast API, no key"]
Browser -->|"1. POST /chat or /agui"| Orchestrator
Orchestrator -->|"8. AG-UI event stream, SSE"| Browser
Orchestrator -->|"2. read profile, 9. write memories"| Mem0
AGENTS -.->|"user-scoped memory search"| Mem0
Mem0 --> Qdrant
Orchestrator -->|"3. checkpoints"| Postgres
Orchestrator -->|"4a. A2A message/stream"| Destination
Orchestrator -->|"4b. A2A message/stream"| Logistics
Orchestrator -->|"7. A2A message/stream"| Activities
Destination -->|"5. kb_search"| Rag
Logistics -->|"6. flights_search, hotels_search"| Travel
Activities -->|"7a. weather_forecast"| Weather
Activities -->|"7b. kb_search"| Rag
Rag -->|"pgvector cosine search"| Postgres
Travel -->|"reads fixtures"| Seed
Weather -->|"HTTPS"| OpenMeteo
ORCH -.->|"10. OTLP spans from every service"| Langfuse
AGENTS -.-> Langfuse
MCPS -.-> Langfuse
MEM -.-> Langfuse
classDef entry fill:#312e81,stroke:#818cf8,color:#e0e7ff
classDef agent fill:#4c1d95,stroke:#a78bfa,color:#ede9fe
classDef mcp fill:#134e4a,stroke:#5eead4,color:#ccfbf1
classDef memory fill:#78350f,stroke:#fbbf24,color:#fef3c7
classDef infra fill:#1e293b,stroke:#94a3b8,color:#e2e8f0
classDef ext fill:#334155,stroke:#94a3b8,color:#e2e8f0,stroke-dasharray: 4 4
class Browser,Orchestrator entry
class Destination,Activities,Logistics agent
class Rag,Weather,Travel mcp
class Mem0,Qdrant memory
class Postgres,Seed,Langfuse infra
class OpenMeteo ext
What happens when
- The browser (or any AG-UI client) sends the trip request to
POST /chat, or a standardRunAgentInputtoPOST /agui. The orchestrator opens the SSE stream and emitsRUN_STARTED. - Memory read: the orchestrator asks mem0
POST /searchfor the traveler profile (mem0 searches Qdrant). The profile reaches the UI asSTATE_SNAPSHOT. - Plan: the LLM turns request plus profile into a structured trip plan (destination,
dates, interests, budget), streamed as a
STATE_DELTApatch. All graph state is checkpointed in Postgres under the conversation's thread id. - Fan-out over A2A
message/stream, in parallel: 4a the destination agent writes a cited briefing, 4b the logistics agent collects flights and hotels. Each subagent restores the trace context from A2A metadata and runs its own domain-scoped mem0 search. Their live status updates stream to the UI asCUSTOMagent_activityevents. - The destination agent grounds every fact through
kb_searchon rag-mcp, which does pgvector cosine search over the ingested city guides and returns chunks with source and score. - The logistics agent queries travel-mcp for both flight directions and for hotels in the requested price tier (deterministic seed fixtures).
- As soon as the destination briefing arrives, the activities agent plans day by day: 7a it fetches the forecast from weather-mcp (Open-Meteo) and 7b pulls concrete spots from rag-mcp, putting outdoor days on dry days.
- Synthesize: the orchestrator merges the three reports into the final markdown
itinerary and streams it token by token as
TEXT_MESSAGE_CONTENTdeltas, closing withRUN_FINISHED. - Background memory write: the full conversation goes to mem0
POST /memories; Mem0's extraction decides what is durable ("loves food and hiking") and stores it in Qdrant. The response stream never waits for this. - Observability: every service exports OTLP spans to Langfuse, and the
traceparentcarried in A2A metadata keeps orchestrator, subagents, MCP tools and mem0 calls in one nested trace per request.
Quickstart
cp .env.example .env # then set OPENAI_API_KEY
docker compose up --build # first boot takes a few minutes (langfuse + builds)
uv run scripts/ingest.py # seed the knowledge base (idempotent)
Then open the web UI at http://localhost:8000 and plan a trip, for example: "Plan 4 days in Lisbon in October, we love food and hiking".
Or use curl:
curl -N -X POST localhost:8000/chat \
-H "Content-Type: application/json" \
-d '{"user_id":"demo","message":"Plan 4 days in Lisbon in October, we love food and hiking"}'
Traces: http://localhost:3000 (login with LANGFUSE_INIT_USER_EMAIL / LANGFUSE_INIT_USER_PASSWORD
from your .env, defaults admin@example.com / password1234).
The web UI
The orchestrator serves a single page app at http://localhost:8000, styled like a boutique hotel site: a photo hero with a floating booking card, live status cards for the concierge team of four agents, the extracted trip plan, the remembered traveler preferences and the final itinerary streaming in as markdown. Fonts are self-hosted; the hero photo ships with the repo (Unsplash license).
All endpoints
Orchestrator (http://localhost:8000)
| Method | Path | Description |
|---|---|---|
| GET | / | Web UI (single page app) |
| GET | /static/* | UI assets: hero.jpg, self-hosted fonts (fonts/playfair-latin.woff2, fonts/playfair-italic-latin.woff2, fonts/inter-latin.woff2) |
| POST | /chat | Plan a trip. Body: {"user_id": "demo", "message": "...", "session_id": "optional"}. Responds with an SSE stream of official AG-UI protocol events: RUN_STARTED, STEP_STARTED/STEP_FINISHED (steps: memory_read, plan, destination, logistics, activities, synthesize), STATE_SNAPSHOT/STATE_DELTA (traveler profile + trip plan), CUSTOM name agent_activity (live subagent status), TEXT_MESSAGE_START/CONTENT/END (the streamed itinerary), RUN_FINISHED, RUN_ERROR |
| POST | /agui | The same run behind the official AG-UI HTTP binding: accepts a standard RunAgentInput (threadId, runId, messages, forwardedProps.user_id), streams the same AG-UI events. Point any AG-UI client (e.g. CopilotKit) here |
| GET | /agents | The three discovered A2A agent cards. Browsers get a styled viewer page; API clients (Accept: application/json) get the raw cards |
| GET | /health | Liveness probe |
| GET | /docs | OpenAPI docs (Swagger UI) |
| GET | /openapi.json | OpenAPI schema |
Destination agent (http://localhost:8101), Activities agent (http://localhost:8102), Logistics agent (http://localhost:8103)
All three are A2A servers with the same surface:
| Method | Path | Description |
|---|---|---|
| GET | /.well-known/agent-card.json | A2A agent card (name, skills, capabilities.streaming = true) |
| POST | / | A2A JSON-RPC endpoint: message/send, message/stream (SSE), tasks/get, tasks/cancel |
| GET | /health | Liveness probe |
Every A2A message from the orchestrator carries metadata.traceparent,
metadata.langfuse_session_id and metadata.user_id. This metadata block is the single
source of truth for cross-service correlation: subagents restore the OTel context from
traceparent and use user_id as their Mem0 scope.
rag-mcp (http://localhost:8201)
| Method | Path | Description |
|---|---|---|
| POST | /mcp | MCP streamable HTTP endpoint (stateless). Methods: initialize, tools/list, tools/call |
| GET | /health | Liveness probe |
Tools: kb_search(query, limit, cursor) (read only, paginated) and
kb_ingest(source, content) (replaces the source's chunks, idempotent).
weather-mcp (http://localhost:8202)
| Method | Path | Description |
|---|---|---|
| POST | /mcp | MCP streamable HTTP endpoint (stateless) |
| GET | /health | Liveness probe |
Tool: weather_forecast(location, days, start_date) (read only, Open-Meteo, no API key).
travel-mcp (http://localhost:8203)
| Method | Path | Description |
|---|---|---|
| POST | /mcp | MCP streamable HTTP endpoint (stateless) |
| GET | /health | Liveness probe |
Tools: flights_search(origin, destination, max_stops, limit, cursor) and
hotels_search(city, tier, limit, cursor), both read only and paginated, backed by
data/seed/flights.json and data/seed/hotels.json.
Inspect any MCP server interactively with:
npx @modelcontextprotocol/inspector and the URL http://localhost:820x/mcp.
mem0 memory service (http://localhost:8300)
| Method | Path | Description |
|---|---|---|
| POST | /memories | Body {"messages": [...], "user_id": "demo", "metadata": {}}. Runs Mem0 extraction and stores durable facts. Only the orchestrator calls this |
| POST | /search | Body {"query": "...", "user_id": "demo", "limit": 5}. Semantic memory search scoped to the user |
| GET | /memories?user_id=demo | List all stored memories for a user |
| DELETE | /memories/{memory_id} | Delete one memory |
| GET | /health | Liveness probe |
| GET | /docs | OpenAPI docs (Swagger UI) |
Infrastructure
| Service | URL | Description |
|---|---|---|
| Langfuse UI | http://localhost:3000 | Traces, sessions, users. OTLP ingest at /api/public/otel (used by all services) |
| Langfuse worker | http://127.0.0.1:3030 | Background worker (localhost only, no user-facing API) |
| Qdrant | http://localhost:6333 | Vector store for mem0. REST API and dashboard at /dashboard |
| Postgres (app) | localhost:5432 | postgresql://app:app@localhost:5432/app. LangGraph checkpoints + kb_chunks pgvector table |
| MinIO (langfuse) | http://localhost:9090 | S3 API for Langfuse event/media uploads. Console at http://127.0.0.1:9091 (minio / miniosecret) |
Internal only (no published app ports): langfuse-postgres, clickhouse, redis.
Development
docker compose up --build # full stack
docker compose up postgres qdrant langfuse-web # infra only, for local dev
uv run --directory agents/orchestrator uvicorn app.main:app --reload
uv run scripts/ingest.py # seed the knowledge base (idempotent)
Seed content lives in data/seed/ (city guides for Lisbon, Porto, Barcelona and Rome,
plus deterministic flight and hotel fixtures). Re-running the ingest script replaces
existing chunks per document.
Notes
- Langfuse: the self-hosted stack is pinned to the official v3 images
(
langfuse/langfuse:3). Langfuse v4 is not yet released for self-hosting; when it is, bumping the two image tags indocker-compose.ymlis the only expected change. - The Langfuse project and login are created headlessly on first boot from the
LANGFUSE_INIT_*variables, so theLANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEYin.envwork immediately.