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.

Trip Planner web UI: beach hero, booking card and the concierge team after a completed run

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

  1. The browser (or any AG-UI client) sends the trip request to POST /chat, or a standard RunAgentInput to POST /agui. The orchestrator opens the SSE stream and emits RUN_STARTED.
  2. Memory read: the orchestrator asks mem0 POST /search for the traveler profile (mem0 searches Qdrant). The profile reaches the UI as STATE_SNAPSHOT.
  3. Plan: the LLM turns request plus profile into a structured trip plan (destination, dates, interests, budget), streamed as a STATE_DELTA patch. All graph state is checkpointed in Postgres under the conversation's thread id.
  4. 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 as CUSTOM agent_activity events.
  5. The destination agent grounds every fact through kb_search on rag-mcp, which does pgvector cosine search over the ingested city guides and returns chunks with source and score.
  6. The logistics agent queries travel-mcp for both flight directions and for hotels in the requested price tier (deterministic seed fixtures).
  7. 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.
  8. Synthesize: the orchestrator merges the three reports into the final markdown itinerary and streams it token by token as TEXT_MESSAGE_CONTENT deltas, closing with RUN_FINISHED.
  9. 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.
  10. Observability: every service exports OTLP spans to Langfuse, and the traceparent carried 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)

MethodPathDescription
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/chatPlan 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/aguiThe 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/agentsThe three discovered A2A agent cards. Browsers get a styled viewer page; API clients (Accept: application/json) get the raw cards
GET/healthLiveness probe
GET/docsOpenAPI docs (Swagger UI)
GET/openapi.jsonOpenAPI 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:

MethodPathDescription
GET/.well-known/agent-card.jsonA2A agent card (name, skills, capabilities.streaming = true)
POST/A2A JSON-RPC endpoint: message/send, message/stream (SSE), tasks/get, tasks/cancel
GET/healthLiveness 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)

MethodPathDescription
POST/mcpMCP streamable HTTP endpoint (stateless). Methods: initialize, tools/list, tools/call
GET/healthLiveness 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)

MethodPathDescription
POST/mcpMCP streamable HTTP endpoint (stateless)
GET/healthLiveness probe

Tool: weather_forecast(location, days, start_date) (read only, Open-Meteo, no API key).

travel-mcp (http://localhost:8203)

MethodPathDescription
POST/mcpMCP streamable HTTP endpoint (stateless)
GET/healthLiveness 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)

MethodPathDescription
POST/memoriesBody {"messages": [...], "user_id": "demo", "metadata": {}}. Runs Mem0 extraction and stores durable facts. Only the orchestrator calls this
POST/searchBody {"query": "...", "user_id": "demo", "limit": 5}. Semantic memory search scoped to the user
GET/memories?user_id=demoList all stored memories for a user
DELETE/memories/{memory_id}Delete one memory
GET/healthLiveness probe
GET/docsOpenAPI docs (Swagger UI)

Infrastructure

ServiceURLDescription
Langfuse UIhttp://localhost:3000Traces, sessions, users. OTLP ingest at /api/public/otel (used by all services)
Langfuse workerhttp://127.0.0.1:3030Background worker (localhost only, no user-facing API)
Qdranthttp://localhost:6333Vector store for mem0. REST API and dashboard at /dashboard
Postgres (app)localhost:5432postgresql://app:app@localhost:5432/app. LangGraph checkpoints + kb_chunks pgvector table
MinIO (langfuse)http://localhost:9090S3 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 in docker-compose.yml is the only expected change.
  • The Langfuse project and login are created headlessly on first boot from the LANGFUSE_INIT_* variables, so the LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY in .env work immediately.