LangGraph Conversation History

This guide continues from LangGraph Getting Started and shows how to record conversation history and expose APIs for frontend applications.

Prerequisites

Starting checkpoint: View the code from the previous section at python/examples/langgraph/doc-checkpoints/02-with-checkpointing

Make sure you’ve completed the LangGraph Getting Started guide first. You should have:

  • A working LangGraph agent with Memory Service checkpointing
  • Memory Service running via Docker Compose
  • OIDC authentication configured

Also complete Step 2 in LangGraph Dev Setup (build local memory-service-langchain wheel + UV_FIND_LINKS); this is temporary until the package is released.

Enable Conversation History Recording

In the previous guide, you added conversation memory through LangGraph checkpointing. Frontend apps also need history channel entries to display conversation turns.

To enable that, wire MemoryServiceHistoryMiddleware into the call_model node and bind request context:

app.py
    api_key=os.getenv("OPENAI_API_KEY", "not-needed-for-tests"),
)

checkpointer = MemoryServiceCheckpointSaver.from_env()
history_middleware = MemoryServiceHistoryMiddleware.from_env()


def call_model(state: MessagesState) -> dict:
    messages = [{"role": "system", "content": "You are a helpful assistant."}] + list(state["messages"])
    user_text = state["messages"][-1].content
    response = history_middleware.wrap_model_call(user_text, lambda: model.invoke(messages))
    return {"messages": [response]}


builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_edge(START, "call_model")
graph = builder.compile(checkpointer=checkpointer)

app = FastAPI(title="LangGraph Chatbot with Conversation History")

What changed: MemoryServiceHistoryMiddleware is imported and instantiated, call_model is updated to call history_middleware.wrap_model_call(user_text, lambda: model.invoke(messages)), and the endpoint wraps graph.ainvoke() in a with memory_service_scope(conversation_id): block.

Why: wrap_model_call records the user’s message and the AI response as a paired history entry in Memory Service after every turn. The memory_service_scope(conversation_id) context manager must be set in the endpoint — not inside the graph node — so the middleware knows which conversation to write to. Without memory_service_scope, the middleware has no conversation ID and cannot write history entries.

Make sure you define a shell function that can get the bearer token for the bob user:

function get-token() {
  curl -sSfX POST http://localhost:8081/realms/memory-service/protocol/openid-connect/token \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "client_id=memory-service-client" \
    -d "client_secret=change-me" \
    -d "grant_type=password" \
    -d "username=bob" \
    -d "password=bob" \
    | jq -r '.access_token'
}

Now test it again.

curl -NsSfX POST http://localhost:9090/chat/53d23c96-6b29-4fbb-879d-db18bf4d94ff \
  -H "Content-Type: text/plain" \
  -H "Authorization: Bearer $(get-token)" \
  -d "Give me a random number between 1 and 100."

Example output:

42

Expose Conversation Entries API

Checkpoint 03 exposes conversation endpoints for frontend apps:

app.py
    return to_fastapi_response(response)


@app.get("/v1/conversations/{conversation_id}/entries")
async def get_entries(conversation_id: str, request: Request):
    response = await proxy.list_conversation_entries(
        conversation_id,
        after_cursor=request.query_params.get("afterCursor"),
        limit=int(limit) if (limit := request.query_params.get("limit")) is not None else None,
        channel="history",
    )
    return to_fastapi_response(response)


@app.get("/v1/conversations")
async def list_conversations(request: Request):
    response = await proxy.list_conversations(
        mode=request.query_params.get("mode"),
        after_cursor=request.query_params.get("afterCursor"),
        limit=int(limit) if (limit := request.query_params.get("limit")) is not None else None,
        query=request.query_params.get("query"),

What changed: GET /v1/conversations/{conversation_id} and GET /v1/conversations/{conversation_id}/entries are added, both implemented by forwarding to MemoryServiceProxy. The entries endpoint hard-codes channel="history" so callers always receive recorded conversation turns.

Why: Frontend apps need these endpoints to display conversation metadata and message history. MemoryServiceProxy forwards the request — along with the user’s bearer token — to Memory Service, then translates the response into a FastAPI-compatible format via to_fastapi_response. Hard-coding channel="history" prevents frontend apps from accidentally reading low-level context channel entries written by the checkpointer.

Test it with curl:

curl -sSfX GET http://localhost:9090/v1/conversations/53d23c96-6b29-4fbb-879d-db18bf4d94ff \
  -H "Authorization: Bearer $(get-token)" | jq

Example output:

42
curl -sSfX GET http://localhost:9090/v1/conversations/53d23c96-6b29-4fbb-879d-db18bf4d94ff/entries \
  -H "Authorization: Bearer $(get-token)" | jq

Example output:

{
  "id": "53d23c96-6b29-4fbb-879d-db18bf4d94ff",
  "title": "Give me a random number between 1 and 10",
  "ownerUserId": "bob",
  "metadata": {},
  "createdAt": "2026-03-06T14:58:32.708132Z",
  "updatedAt": "2026-03-06T14:58:32.875039Z",
  "accessLevel": "owner"
}

Expose Conversation Listing API

To let users see all conversations they can access, expose GET /v1/conversations:

app.py

What changed: GET /v1/conversations is added, forwarding mode, afterCursor, limit, and query parameters to proxy.list_conversations().

Why: Users need a way to retrieve all conversations they have access to — not just a single conversation by ID. By proxying these parameters directly, frontend apps can support pagination (afterCursor, limit) and full-text search (query) over the conversation list without the agent app implementing any listing logic itself.

Test it with curl:

curl -sSfX GET http://localhost:9090/v1/conversations \
  -H "Authorization: Bearer $(get-token)" | jq

Example output:

{
  "afterCursor": null,
  "data": [
    {
      "id": "819753ab-b3ac-42f8-89fb-dd02ec7f4a30",
      "conversationId": "53d23c96-6b29-4fbb-879d-db18bf4d94ff",
      "userId": "bob",
      "clientId": "checkpoint-agent",
      "channel": "history",
      "contentType": "history",
      "createdAt": "2026-03-06T14:58:32.782652Z",
      "content": [
        {
          "role": "USER",
          "text": "Give me a random number between 1 and 100."
        }
      ]
    },
    {
      "id": "4ff191fc-4663-46d5-bf66-e62758e760ec",
      "conversationId": "53d23c96-6b29-4fbb-879d-db18bf4d94ff",
      "userId": "bob",
      "clientId": "checkpoint-agent",
      "channel": "history",
      "contentType": "history",
      "createdAt": "2026-03-06T14:58:32.851076Z",
      "content": [
        {
          "role": "AI",
          "text": "42"
        }
      ]
    }
  ]
}

Completed Checkpoint

Completed code: View the full implementation at python/examples/langgraph/doc-checkpoints/03-with-history

Next Steps

Continue to: