Skip to content

Day 6 -- Chat History

Your trip planner generates great itineraries, but every call starts from scratch. Real users iterate -- "make it cheaper," "add a beach day," "what about hotels near the train station." Today you add conversation memory so the planner remembers what you have discussed.

What we're building today

graph LR
    U[User] -->|"POST /plan"| GW[gateway]
    GW -->|"trip_planning"| PL[planner-agent]
    PL -->|"chat_history"| CH[chat-history-agent]
    PL -->|"+claude"| CP[claude-provider]
    PL -.->|failover| OP[openai-provider]
    PL ==>|tier-1| UPA[user-prefs-agent]
    CP -.->|tier-2| FA[flight-agent]
    CP -.->|tier-2| HA[hotel-agent]
    CP -.->|tier-2| WA[weather-agent]
    CP -.->|tier-2| PA[poi-agent]
    FA -->|depends on| UPA
    PA -->|depends on| WA

    style U fill:#555,color:#fff
    style GW fill:#e67e22,color:#fff
    style CH fill:#1abc9c,color:#fff
    style PL fill:#9b59b6,color:#fff
    style CP fill:#9b59b6,color:#fff
    style OP fill:#9b59b6,color:#fff
    style FA fill:#4a9eff,color:#fff
    style PA fill:#4a9eff,color:#fff
    style UPA fill:#1a8a4a,color:#fff
    style WA fill:#1a8a4a,color:#fff
    style HA fill:#1a8a4a,color:#fff

Ten agents. Everything from Day 5 plus chat-history-agent in teal. The planner fetches prior turns from chat history before calling the LLM, and saves both the user message and the response afterward. The gateway stays thin -- it just passes the session ID through.

Today has four parts:

  1. Build the chat history agent -- a tool agent backed by Redis
  2. Update the planner -- add history fetch and save around the LLM call
  3. Update the gateway -- add session ID passthrough
  4. Walk the trace -- see history calls in the distributed trace

Part 1: Build the chat history agent

Chat history is just another mesh tool agent. The same dependency injection that wires flight-agent wires chat-history-agent. There is no special framework primitive for state -- you write an agent that wraps a data store, and other agents call it like any other tool.

Scaffold the agent

$ meshctl scaffold --name chat-history-agent --agent-type tool --port 9109
Created agent 'chat-history-agent' in chat-history-agent/

Generated files:
  chat-history-agent/
  ├── .dockerignore
  ├── Dockerfile
  ├── README.md
  ├── __init__.py
  ├── __main__.py
  ├── helm-values.yaml
  ├── main.py
  └── requirements.txt

Add Redis to requirements

The agent needs redis-py to talk to the Redis instance from your observability stack (Day 3's docker-compose.observability.yml already runs Redis on port 6379):

# chat-history-agent dependencies
# Add your third-party dependencies here
# Note: mcp-mesh is provided by the runtime environment (local venv or Docker base image)

Replace main.py

Replace the generated main.py with:

import json
import os

import mesh
import redis
from fastmcp import FastMCP

app = FastMCP("Chat History Agent")

redis_client = redis.Redis(
    host=os.getenv("REDIS_HOST", "localhost"),
    port=int(os.getenv("REDIS_PORT", "6379")),
    decode_responses=True,
)


@app.tool()
@mesh.tool(
    capability="chat_history",
    description="Save a conversation turn to Redis",
    tags=["chat", "history", "state"],
)
async def save_turn(session_id: str, role: str, content: str) -> dict:
    """Save a single conversation turn (user or assistant message)."""
    turn = json.dumps({"role": role, "content": content})
    redis_client.rpush(f"chat:{session_id}", turn)
    length = redis_client.llen(f"chat:{session_id}")
    return {"session_id": session_id, "role": role, "saved": True, "total_turns": length}


@app.tool()
@mesh.tool(
    capability="chat_history",
    description="Retrieve recent conversation turns from Redis",
    tags=["chat", "history", "state"],
)
async def get_history(session_id: str, limit: int = 20) -> list[dict]:
    """Retrieve the most recent turns for a session."""
    raw = redis_client.lrange(f"chat:{session_id}", -limit, -1)
    return [json.loads(entry) for entry in raw]


@mesh.agent(
    name="chat-history-agent",
    version="1.0.0",
    description="TripPlanner Redis-backed chat history (Day 6)",
    http_port=9109,
    enable_http=True,
    auto_run=True,
)
class ChatHistoryAgent:
    pass

Two tools, one capability. save_turn appends a JSON-encoded turn to a Redis list keyed by session ID. get_history reads the most recent turns from that list. Both tools share the chat_history capability -- when the planner declares a dependency on chat_history, mesh injects a proxy that can call either tool by name.

The Redis connection is straightforward: a module-level redis.Redis client pointed at localhost:6379 (configurable via environment variables for Docker/Kubernetes deployment).

redis_client = redis.Redis(
    host=os.getenv("REDIS_HOST", "localhost"),
    port=int(os.getenv("REDIS_PORT", "6379")),
    decode_responses=True,
)

Why this works

Swap Redis for Postgres by editing one agent. Add encryption by extending one agent. The gateway and planner do not move. mesh does not need a chat history primitive -- the general abstraction (any MCP tool anywhere is a local function call) handles it.

Part 2: Update the planner

The planner gains chat history as a tier-1 dependency alongside user preferences. It fetches history before the LLM call and saves turns after. The gateway stays thin -- it just passes the session ID.

import mesh
from fastmcp import FastMCP
from mesh import MeshContextModel
from pydantic import Field

app = FastMCP("Planner Agent")


class TripRequest(MeshContextModel):
    """Context model for the trip planning prompt template."""

    destination: str = Field(..., description="Travel destination city")
    dates: str = Field(..., description="Travel dates (e.g. June 1-5, 2026)")
    budget: str = Field(..., description="Total trip budget (e.g. $2000)")
    user_preferences: str = Field(
        default="", description="User travel preferences (injected at runtime)"
    )


@app.tool()
@mesh.llm(
    system_prompt="file://prompts/plan_trip.j2",
    context_param="ctx",
    provider={"capability": "llm", "tags": ["+claude"]},
    filter=[
        {"capability": "flight_search"},
        {"capability": "hotel_search"},
        {"capability": "weather_forecast"},
        {"capability": "poi_search"},
    ],
    filter_mode="all",
    max_iterations=10,
)
@mesh.tool(
    capability="trip_planning",
    description="Generate a trip itinerary using an LLM with real travel data",
    tags=["planner", "travel", "llm"],
    dependencies=["user_preferences", "chat_history"],
)
async def plan_trip(
    destination: str,
    dates: str,
    budget: str,
    message: str = "",
    session_id: str = "",
    conversation_history: list[dict] = [],
    user_prefs: mesh.McpMeshTool = None,
    chat_history: mesh.McpMeshTool = None,
    ctx: TripRequest = None,
    llm: mesh.MeshLlmAgent = None,
) -> str:
    """Plan a trip given a destination, dates, and budget."""
    # Tier-1: prefetch user preferences before the LLM call
    prefs = {}
    if user_prefs:
        prefs = await user_prefs(user_id="demo-user")

    # Inject preferences into the context so the Jinja template can use them
    prefs_summary = (
        f"Preferred airlines: {', '.join(prefs.get('preferred_airlines', []))}. "
        f"Budget limit: ${prefs.get('budget_usd', 'flexible')}. "
        f"Interests: {', '.join(prefs.get('interests', []))}. "
        f"Minimum hotel stars: {prefs.get('hotel_min_stars', 'any')}."
        if prefs
        else "No user preferences available."
    )

    # Tier-1: fetch chat history if a session is active
    history = []
    if session_id and chat_history:
        history = await chat_history.call_tool("get_history", {
            "session_id": session_id,
            "limit": 20,
        })
        if isinstance(history, dict):
            history = history.get("result", [])

    # Build the message for the LLM. When history is present,
    # pass the full turn list so the LLM sees prior context.
    user_text = message or (
        f"Plan a trip to {destination} from {dates} with a budget of {budget}."
    )

    if history:
        messages = list(history)
        messages.append({"role": "user", "content": user_text})
        result = await llm(
            messages,
            context={"user_preferences": prefs_summary},
        )
    else:
        result = await llm(
            user_text,
            context={"user_preferences": prefs_summary},
        )

    # Save turns to chat history so the next request sees them
    if session_id and chat_history:
        await chat_history.call_tool("save_turn", {
            "session_id": session_id,
            "role": "user",
            "content": user_text,
        })
        response_text = result if isinstance(result, str) else str(result)
        await chat_history.call_tool("save_turn", {
            "session_id": session_id,
            "role": "assistant",
            "content": response_text,
        })

    return result


@mesh.agent(
    name="planner-agent",
    version="1.0.0",
    description="TripPlanner LLM planner with tool access (Day 6)",
    http_port=9107,
    enable_http=True,
    auto_run=True,
)
class PlannerAgent:
    pass

Dependency declaration

The @mesh.tool decorator now declares two dependencies instead of one:

    dependencies=["user_preferences", "chat_history"],

Both user_preferences and chat_history are tier-1 dependencies -- resolved before the tool function runs. The planner calls chat_history.call_tool("get_history", {...}) and chat_history.call_tool("save_turn", {...}) because the chat_history capability exposes two tools. For user_prefs, the single-tool shorthand (await user_prefs(...)) still works.

History fetch

Before the LLM call, the planner fetches the conversation history for the current session:

    # Tier-1: fetch chat history if a session is active
    history = []
    if session_id and chat_history:
        history = await chat_history.call_tool("get_history", {
            "session_id": session_id,
            "limit": 20,
        })
        if isinstance(history, dict):
            history = history.get("result", [])

Multi-turn messages

When history is present, the planner passes the full message list to the LLM instead of a single string:

    # Build the message for the LLM. When history is present,
    # pass the full turn list so the LLM sees prior context.
    user_text = message or (
        f"Plan a trip to {destination} from {dates} with a budget of {budget}."
    )

    if history:
        messages = list(history)
        messages.append({"role": "user", "content": user_text})
        result = await llm(
            messages,
            context={"user_preferences": prefs_summary},
        )
    else:
        result = await llm(
            user_text,
            context={"user_preferences": prefs_summary},
        )

The @mesh.llm decorator handles multi-turn natively -- pass a list of {"role": "...", "content": "..."} dicts as the first argument to llm() and the decorator builds the correct LLM API call. The system prompt from the Jinja2 template is inserted automatically.

History save

After the LLM responds, the planner saves both the user turn and the assistant turn so the next request sees them:

    # Save turns to chat history so the next request sees them
    if session_id and chat_history:
        await chat_history.call_tool("save_turn", {
            "session_id": session_id,
            "role": "user",
            "content": user_text,
        })
        response_text = result if isinstance(result, str) else str(result)
        await chat_history.call_tool("save_turn", {
            "session_id": session_id,
            "role": "assistant",
            "content": response_text,
        })

Part 3: Update the gateway

The gateway gains a session_id parameter. Everything else stays the same -- one dependency, five lines of code.

import uuid

import mesh
from fastapi import FastAPI, Request
from mesh.types import McpMeshTool

app = FastAPI(title="Trip Planner Gateway", version="2.0.0")


@app.get("/health")
async def health():
    """Health check endpoint."""
    return {"status": "healthy"}


@app.post("/plan")
@mesh.route(dependencies=["trip_planning"])
async def plan_trip(request: Request, plan_trip: McpMeshTool = None):
    """Bridge HTTP to the mesh planner with session tracking."""
    body = await request.json()
    if not plan_trip:
        return {"error": "trip_planning capability unavailable"}

    session_id = request.headers.get("X-Session-Id") or str(uuid.uuid4())

    result = await plan_trip(
        destination=body["destination"],
        dates=body["dates"],
        budget=body["budget"],
        message=body.get("message", ""),
        session_id=session_id,
    )
    return {"result": result, "session_id": session_id}


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=8080, log_level="info")

Session ID

    session_id = request.headers.get("X-Session-Id") or str(uuid.uuid4())

If the client sends X-Session-Id, the gateway uses it. Otherwise it generates a UUID and returns it in the response so the client can use it for follow-up calls. The gateway passes session_id to the planner alongside the trip parameters -- the planner handles the rest.

Start and test

Install redis-py

If redis is not already in your venv:

$ pip install redis

Start the chat history agent

Your nine agents from Day 5 should still be running. Add chat-history-agent:

$ meshctl start --dte --debug -d -w chat-history-agent/main.py

If you are starting fresh, launch everything at once:

$ meshctl start --dte --debug -d -w \
    chat-history-agent/main.py \
    claude-provider/main.py \
    openai-provider/main.py \
    flight-agent/main.py \
    hotel-agent/main.py \
    weather-agent/main.py \
    poi-agent/main.py \
    user-prefs-agent/main.py \
    planner-agent/main.py \
    gateway/main.py

Check the mesh:

$ meshctl list
Registry: running (http://localhost:8000) - 10 healthy

NAME                             RUNTIME   TYPE    STATUS    DEPS   ENDPOINT           AGE   LAST SEEN
chat-history-agent-3f2a1b9c      Python    Agent   healthy   0/0    10.0.0.74:9109     8s    2s
claude-provider-0a89e8c6         Python    Agent   healthy   0/0    10.0.0.74:49486    15m   2s
flight-agent-a939da4b            Python    Agent   healthy   1/1    10.0.0.74:49480    15m   2s
gateway-7b3f2e91                 Python    API     healthy   1/1    10.0.0.74:8080     5m    2s
hotel-agent-9932ac09             Python    Agent   healthy   0/0    10.0.0.74:49482    15m   2s
openai-provider-40a5c637         Python    Agent   healthy   0/0    10.0.0.74:49485    15m   2s
planner-agent-fb07b918           Python    Agent   healthy   2/2    10.0.0.74:49484    15m   2s
poi-agent-97bd9fcc               Python    Agent   healthy   1/1    10.0.0.74:49481    15m   2s
user-prefs-agent-87506c4a        Python    Agent   healthy   0/0    10.0.0.74:49479    15m   2s
weather-agent-a6f7ea5e           Python    Agent   healthy   0/0    10.0.0.74:49483    15m   2s

Ten agents. The gateway shows 1/1 dependency -- just trip_planning. The planner shows 2/2 dependencies -- it resolved both user_preferences and chat_history.

List the tools:

$ meshctl list --tools
TOOL                      AGENT                            CAPABILITY           TAGS
-----------------------------------------------------------------------------------------------
claude_provider           claude-provider-0a89e8c6         llm                  claude
flight_search             flight-agent-a939da4b            flight_search        flights,travel
get_history               chat-history-agent-3f2a1b9c      chat_history         chat,history,state
get_user_prefs            user-prefs-agent-87506c4a        user_preferences     preferences,travel
get_weather               weather-agent-a6f7ea5e           weather_forecast     weather,travel
hotel_search              hotel-agent-9932ac09             hotel_search         hotels,travel
openai_provider           openai-provider-40a5c637         llm                  openai,gpt
plan_trip                 planner-agent-fb07b918           trip_planning        planner,travel,llm
save_turn                 chat-history-agent-3f2a1b9c      chat_history         chat,history,state
search_pois               poi-agent-97bd9fcc               poi_search           poi,travel

10 tool(s) found

Two new tools: save_turn and get_history, both from chat-history-agent.

Mesh UI Topology showing ten agents with chat-history-agent connected to planner

Multi-turn demo

Turn 1 -- plan a trip:

$ curl -s -X POST http://localhost:8080/plan \
    -H "Content-Type: application/json" \
    -H "X-Session-Id: test-session-1" \
    -d '{"destination":"Kyoto","dates":"June 1-5, 2026","budget":"$2000"}'
{
  "result": "## Kyoto Trip Itinerary: June 1-5, 2026\n\n**Budget: $2,000**\n\n### Day 1 (June 1) - Arrival & Eastern Kyoto\n\n**Morning:**\n- Arrive via SQ017 ($901) — preferred airline per your preferences\n- Check into Sakura Inn ($95/night, 3-star) — meets your minimum star rating\n\n**Afternoon:**\n- Visit Fushimi Inari Shrine (cultural — matches your interests)\n...",
  "session_id": "test-session-1"
}

Turn 2 -- iterate on the plan:

$ curl -s -X POST http://localhost:8080/plan \
    -H "Content-Type: application/json" \
    -H "X-Session-Id: test-session-1" \
    -d '{"destination":"Kyoto","dates":"June 1-5, 2026","budget":"$1500","message":"Can you make it cheaper? I want to stay under $1500."}'
{
  "result": "## Revised Kyoto Itinerary: June 1-5, 2026\n\n**Budget: $1,500** (revised from $2,000)\n\n### Changes from Previous Plan\n- Switched to MH007 ($842, saving $59) — still a preferred airline\n- Downgraded to Capsule Stay ($45/night, saving $200 over 4 nights)\n- Replaced paid attractions with free alternatives\n\n### Day 1 (June 1) - Arrival\n...",
  "session_id": "test-session-1"
}

The second response references the first plan -- it knows about the previous hotel choice, the original budget, and the itinerary structure. This is the conversation history at work: the planner fetched the prior turns from Redis, passed them to the LLM as a multi-turn message list, and the LLM responded with awareness of the full dialogue.

Turn 3 -- ask a question:

$ curl -s -X POST http://localhost:8080/plan \
    -H "Content-Type: application/json" \
    -H "X-Session-Id: test-session-1" \
    -d '{"destination":"Kyoto","dates":"June 1-5, 2026","budget":"$1500","message":"What if I skip the flight and take the Shinkansen from Tokyo instead?"}'

The planner sees all three turns and adjusts accordingly. Each turn adds to the Redis list, and the next request reads the full history.

Part 4: Walk the trace

Open the mesh UI to view the trace:

$ meshctl start --ui -d

Navigate to http://localhost:3080 and click the most recent trace. The call tree shows the planner's orchestration -- history fetch and save happen inside the planner, not the gateway:

└─ plan_trip (planner-agent) [18542ms] ✓
   ├─ get_history (chat-history-agent) [2ms] ✓
   ├─ get_user_prefs (user-prefs-agent) [1ms] ✓
   ├─ claude_provider (claude-provider) [18451ms] ✓
   │  ├─ flight_search (flight-agent) [14ms] ✓
   │  │  └─ get_user_prefs (user-prefs-agent) [0ms] ✓
   │  ├─ hotel_search (hotel-agent) [1ms] ✓
   │  ├─ get_weather (weather-agent) [0ms] ✓
   │  └─ search_pois (poi-agent) [21ms] ✓
   │     └─ get_weather (weather-agent) [0ms] ✓
   ├─ save_turn (chat-history-agent) [1ms] ✓
   └─ save_turn (chat-history-agent) [1ms] ✓

The flow reads top to bottom: fetch history (2ms), prefetch user preferences (1ms), run the LLM (18s, most of which is the LLM reasoning loop), save the user message (1ms), save the assistant response (1ms). The chat history calls add negligible overhead -- Redis round-trips are sub-millisecond.

Stateful concerns are just agents

Redis-backed chat history, user profiles, booking state, audit logs -- they are all the same pattern: a mesh tool agent wrapping a data store. mesh does not need a special primitive for each one. The general abstraction -- any MCP tool anywhere is a local function call -- handles them all. Want to swap Redis for Postgres? Edit one agent. Want to add message encryption? Extend one agent. The gateway and planner do not change.

Leave it running

Your ten agents are running in watch mode. On Day 7 you will add a committee of specialists. No need to stop between chapters.

Troubleshooting

Redis connection refused. The chat-history-agent connects to Redis on localhost:6379. Make sure the observability stack is running:

$ docker compose -f docker-compose.observability.yml up -d

Check Redis is healthy:

$ docker compose -f docker-compose.observability.yml ps redis

History not persisting across calls. Verify you are sending the same X-Session-Id header in both requests. If the header is missing, the gateway generates a new UUID for each call -- each turn gets its own session with no shared history. Check the session_id field in the response.

Second turn does not reference the first. Three things to check:

  1. The chat_history dependency resolved: meshctl list should show the planner with 2/2 deps.
  2. Redis contains the turns: redis-cli LRANGE chat:test-session-1 0 -1 should show the saved JSON.
  3. The planner received the history: check the trace for get_history returning a non-empty list. If the planner's max_iterations is too low, the LLM may not fully process the history before hitting the iteration cap.

ModuleNotFoundError: No module named 'redis'. Install redis-py in your venv:

$ pip install redis

Recap

You added multi-turn chat history to the trip planner by building one new agent and updating two existing ones. The chat-history-agent wraps Redis with two tools (save_turn, get_history). The planner owns the full chat lifecycle -- it fetches history before the LLM call and saves turns after. The gateway stays thin: one dependency, session ID passthrough. No framework changes, no special chat primitives -- just another mesh tool agent wired through dependency injection.

See also

  • meshctl man decorators -- the @mesh.tool and @mesh.route decorator reference
  • meshctl man dependency-injection -- how DI resolves multi-tool capabilities
  • meshctl man llm -- multi-turn message format for llm() calls

Next up

Day 7 adds a committee of specialists -- three LLM agents (budget analyst, adventure advisor, logistics planner) that the planner consults in parallel before producing the final itinerary.