LangGraph Getting Started

This guide walks you through a minimal LangGraph chatbot first, then adds incremental memory-service integration. The goal is to keep code changes small while unlocking memory features one step at a time.

Make sure you have completed LangGraph Dev Setup first. Also complete Step 2 on that page (build local memory-service-langchain wheel + UV_FIND_LINKS); this is temporary until the package is released.

Step 1: Start with a Minimal LangGraph Chatbot

Starting checkpoint: python/examples/langgraph/doc-checkpoints/01-basic-langgraph

Create a minimal LangGraph chatbot using StateGraph(MessagesState) and expose it over HTTP with FastAPI (no memory-service imports yet):

app.py
from __future__ import annotations

import os

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import PlainTextResponse
from langchain_openai import ChatOpenAI
from langgraph.graph import START, StateGraph
from langgraph.graph.message import MessagesState


openai_base_url = os.getenv("OPENAI_BASE_URL")
if openai_base_url and not openai_base_url.rstrip("/").endswith("/v1"):
    openai_base_url = openai_base_url.rstrip("/") + "/v1"

model = ChatOpenAI(
    model=os.getenv("OPENAI_MODEL", "gpt-4o"),
    openai_api_base=openai_base_url,
    api_key=os.getenv("OPENAI_API_KEY", "not-needed-for-tests"),
)


def call_model(state: MessagesState) -> dict:
    messages = [{"role": "system", "content": "You are a helpful assistant."}] + list(state["messages"])
    response = 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()

app = FastAPI(title="LangGraph Chatbot")


@app.get("/ready")
async def ready() -> dict[str, str]:
    return {"status": "ok"}


@app.post("/chat")
async def chat(request: Request) -> PlainTextResponse:
    user_message = (await request.body()).decode("utf-8").strip()
    if not user_message:
        raise HTTPException(400, "message is required")

    result = await graph.ainvoke(
        {"messages": [{"role": "user", "content": user_message}]}
    )
    message = result["messages"][-1]
    content = getattr(message, "content", "")
    return PlainTextResponse(content if isinstance(content, str) else str(content))

Why: StateGraph(MessagesState) is LangGraph’s way of describing the computation as a directed graph where each node transforms state. MessagesState gives the graph a built-in messages list so nodes can append to it naturally. Starting with a single node keeps the structure clear before adding memory features.

Add LangGraph dependencies:

pyproject.toml
[project]
name = "langgraph-doc-checkpoint-01-basic-langgraph"
version = "0.1.0"
description = "Memory Service LangGraph docs checkpoint app"
requires-python = ">=3.10"
dependencies = [
  "fastapi>=0.115.0,<1.0.0",
  "langgraph>=1.0.0,<2.0.0",
  "langchain-openai>=1.0.0,<2.0.0",
  "uvicorn>=0.34.0,<1.0.0",
]

[tool.uv]
package = false

The checkpoint reads model configuration from environment variables (OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY) so local dev and site-tests can inject provider settings without code changes.

Run the app:

cd python/examples/langgraph/doc-checkpoints/01-basic-langgraph
uv sync --frozen
uv run uvicorn app:app --host 0.0.0.0 --port 9090

Test it with curl:

curl -NsSfX POST http://localhost:9090/chat \
  -H "Content-Type: text/plain" \
  -d "What is 1 + 1?"

Example output:

1 + 1 = 2.

Each request is stateless — the chatbot has no memory of previous turns.

Step 2: Enable Memory-Backed Conversations

Starting checkpoint: python/examples/langgraph/doc-checkpoints/01-basic-langgraph

Checkpoint 02 adds MemoryServiceCheckpointSaver so the LangGraph graph persists conversation state across requests, keyed by conversation_id.

app.py
from __future__ import annotations

import os

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import PlainTextResponse
from langchain_openai import ChatOpenAI
from langgraph.graph import START, StateGraph
from langgraph.graph.message import MessagesState
from memory_service_langchain import MemoryServiceCheckpointSaver, install_fastapi_authorization_middleware


openai_base_url = os.getenv("OPENAI_BASE_URL")
if openai_base_url and not openai_base_url.rstrip("/").endswith("/v1"):
    openai_base_url = openai_base_url.rstrip("/") + "/v1"

What changed: MemoryServiceCheckpointSaver and install_fastapi_authorization_middleware are imported from memory_service_langchain, and a checkpointer = MemoryServiceCheckpointSaver.from_env() instance is created at module level.

Why: MemoryServiceCheckpointSaver is a LangGraph BaseCheckpointSaver that stores and loads graph state in the Memory Service context channel. install_fastapi_authorization_middleware reads the Authorization: Bearer <token> header from every incoming request and stores it in a request-scoped context variable so the checkpointer can forward it to Memory Service without you passing it explicitly.

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


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

What changed: checkpointer=checkpointer is passed to builder.compile(), and install_fastapi_authorization_middleware(app) is called after the FastAPI app is created.

Why: Passing checkpointer to compile() makes LangGraph automatically save the graph state after every node and load it at the start of every subsequent call with the same thread_id. This is the LangGraph pattern — unlike the LangChain create_agent() API, the checkpointer is attached at compile time rather than agent-creation time.

The endpoint extracts conversation_id from the path and threads it into the graph as thread_id:

app.py
@app.get("/ready")
async def ready() -> dict[str, str]:
    return {"status": "ok"}
install_fastapi_authorization_middleware(app)


@app.post("/chat/{conversation_id}")
async def chat(conversation_id: str, request: Request) -> PlainTextResponse:
    user_message = (await request.body()).decode("utf-8").strip()
    if not user_message:
        raise HTTPException(400, "message is required")

    result = await graph.ainvoke(

What changed: The route changes from POST /chat to POST /chat/{conversation_id}, and config={"configurable": {"thread_id": conversation_id}} is passed to graph.ainvoke().

Why: By mapping thread_id to conversation_id from the URL path, each unique path corresponds to one persistent conversation thread. Two calls to /chat/abc share the same message history; a call to /chat/xyz starts a fresh one.

Make sure Memory Service and Keycloak are running, then define a helper to get a user token:

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'
}
curl -NsSfX POST http://localhost:9090/chat/08c5bb33-05aa-4e4d-85cd-a1dd7ab1e137 \
  -H "Authorization: Bearer $(get-token)" \
  -H "Content-Type: text/plain" \
  -d "Hi, I'm Hiram, who are you?"

Example output:

Hi Hiram! I am a LangGraph memory-service demo agent.
curl -NsSfX POST http://localhost:9090/chat/08c5bb33-05aa-4e4d-85cd-a1dd7ab1e137 \
  -H "Authorization: Bearer $(get-token)" \
  -H "Content-Type: text/plain" \
  -d "Who am I?"

Example output:

You are Hiram, as you told me in your introduction.

Next Steps

Continue to Conversation History to record user/AI turns and expose conversation APIs for frontend clients.