Vol. 25 · Application Engineering
Starter Kit: FastAPI + LangGraph + LangChain by File
It's almost a project template. Not an abstract architecture, but a small, but production-focused starter kit: what files to create, what to put in them, how to assemble a ‘FastAPI’ service, where ‘LangGraph’ and ‘LangChain’ live, how to add durability, streaming, ‘resume’, tests, Docker and an optional ‘MCP’ layer.
What We Build
01Frame.Starter kit is a small service that already looks like a live product.
The idea is not to show 500 files, but to assemble a minimum set that is already fairly similar to production: there are ‘FastAPI’, service layer, compiled graph, persistence, streaming, ‘resume’, health endpoints, Docker and tests. After that, you can already increase the domain logic.
textSTART COMPLEX
public HTTP API
↓
service layer
↓
LangGraph runtime
↓
LangChain components
↓
external persistence + external tools02Frame.The starter kit has three modes: “chat”, “stream”, “resume”.
If you make only one sync endpoint, the project will look like a demo. As soon as streaming and durable human-in-the-loop paths through ‘resume’ appear, code behaves like a real service rather than a playground.
minimum
- sync chat
- streaming
- interrupt / resume
03Frame.Startup project should be stateless from above and durable from below
This is one of the main engineering ideas. An HTTP process doesn’t have to be the only place where a conversation lives. It is better to keep the service as stateless as possible, and the thread state and memory are transferred to external persistence, so that you can experience restarts and scaling.
textweb process:
preferably disposable and replaceable
conversation state:
live outside the process04Frame.Starter kit is more important than perfect business logic.
At the start, it is more important for you to learn how to properly separate layers: input API, config, state/context, tools, graph build, service orchestration, persistence, tests. If these boundaries are correct, then you can easily change prompts, tools and even entire graph flow without destroying the base.
layered
- api
- agent
- infra
- tests
05Frame.One good starter kit is better than five laptop examples.
In production, most of the time is eaten not by models, but by glue code and lifecycle: service start, config, resources, streaming, errors, resumes, health checks and tracing. Starter kit is valuable because it shows these boring but real things at once.
That's why it's here
- lifespan
- app.state
- Docker
- tests
File Starter Kit
06StarterOne of the most normal minimum repo trees
This structure is already sufficient for a live microservice and is not overloaded. There's a clear input, a separate agent layer, an infrastructure layer and tests.
textREPO TREE
app/
main.py
core/
settings.py
logging.py
api/
deps.py
routers/
chat.py
health.py
agent/
contracts/
state.py
context.py
schemas.py
components/
models.py
prompts.py
tools.py
middleware.py
graph/
nodes.py
routes.py
build.py
services/
chat_service.py
stream_service.py
infra/
persistence.py
mcp_clients.py
tests/
Dockerfile
pyproject.toml07Starterpyproject.toml - fix the stack immediately
In the starting skeleton, it is useful to immediately fix the dependencies so that the project is reproducible. This service usually requires fastapi, uvicorn, langchain, langgraph, pydantic, pydantic-settings and test stack.
textdependencies = [
"fastapi",
"uvicorn[standard]",
"langchain",
"langgraph",
"pydantic",
"pydantic-settings",
]
dev = [
"pytest",
"httpx",
]08Starter`app/` - application, `tests/` - separate external view
It seems trivial, but separation is very helpful. `tests/` shall not be embedded in graph modules. They look at the system from the outside and check HTTP, service wiring and graph behavior as separate layers.
Why is it useful?
- less connectivity
- Tests closer to actual use
09StarterThis is where the naming convention comes in.
If you don't immediately decide where you have 'thread id', where 'run id', as routers and service methods are called, then things get confusing. Starter kit is also useful because it sets the project language: “chat”, “stream”, “resume”, “health”, “build graph”, “RequestContext”, “AgentState”.
textHTTP:
/v1/chat
/v1/chat/stream
/v1/threads/{thread_id}/resume
Python:
ChatRequest
ChatResponse
ChatService
build_graph
RequestContext
AgentState10StarterStarter kit is better done as a single repository, not as a zoo microservices
At the start, it is more useful to assemble one quality service with all layers than it is premature to break everything down into “api-service”, “graph-service”, “tool-service”, “mcp-service”. You will always have time to divide into several services later, when real operational pain appears.
better off
- repo
- One deployable service
- MCP can be added as an option later.
Core Files and Start of Service
11Core‘core/settings.py’ – Config must come from env, not code
Almost any living system quickly begins to live in different environments: local, staging, prod. Therefore, the settings of the model, DSN, timeouts and flags are better put in the settings model, rather than smeared into modules.
pythonclass Settings(BaseSettings):
app_name: str = "starter-agent"
model_name: str = "provider:model"
api_prefix: str = "/v1"
log_level: str = "INFO"
request_timeout_s: int = 60
database_url: str
def get_settings() -> Settings:
return Settings()12Core'main.py' must collect resources through 'lifespan'
This is one of the main production patterns of FastAPI. At the start of the application, you once create settings, persistence, compiled graph and service objects, fold them into “app.state”, and the routes then only get the finished objects.
sqlfrom contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
checkpointer, store = await build_persistence(settings)
graph = build_graph(settings=settings, checkpointer=checkpointer, store=store)
app.state.chat_service = ChatService(graph=graph, settings=settings)
yield
app = FastAPI(lifespan=lifespan)
app.include_router(chat_router)
app.include_router(health_router)13Core`api/deps.py' - a place for service injection and request context
Dependencies in FastAPI is convenient to use not only for auth, but also for access to objects from "app.state". It is also good to build a request-scoped context, which then falls into the graph runtime.
pythondef get_chat_service(request: Request) -> ChatService:
return request.app.state.chat_service
def get_request_context() -> RequestContext:
return RequestContext(
user_id="demo-user",
tenant_id="demo-tenant",
)14Core“core/logging.py” should be added immediately, even if it is small.
If the logging is not centralized at the beginning, then it spreads through the “print()” and random “basicConfig”. For a starter kit, one place is enough to configure the format, level, and correlation fields like ‘request id’ and ‘thread id’.
logs are useful
- request_id
- thread_id
- run_id
15CoreIn the starter kit, it is already useful to distinguish “RequestContext” separately from “AgentState”.
This distinction is very disciplined. “RequestContext” contains immutable launch data: who is the user, which tenant, what rights. AgentState is an evolving state of execution. If you mix them, the count will begin to live with too many duties at once.
texcontext = who started the run and what conditions
What happened inside the run?Contracts, Components and Graph
16Graph*contracts/state.py - the main contract inside runtime
The state of the graph should be short, understandable and suitable for serialization. For a starter kit, you almost always need “messages”, a pair of domain fields and a flag for human review.
sqlfrom typing_extensions import TypedDict, Annotated
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
answer: dict | None
review_required: bool | None
metadata: dict17Graph`components/models.py`, `tools.py`, `middleware.py` is a layer of LangChain building blocks
This is where the model, tools and middleware live. Even if you have orchestration by hand on 'LangGraph', this layer is conveniently assembled according to the canons of LangChain: 'init chat model', '@tool', 'ToolRuntime', '@wrap tool call', '@dynamic prompt' and structured output contracts.
python@tool
def get_profile(runtime: ToolRuntime[RequestContext]) -> str:
user_id = runtime.context.user_id
return f"profile for {user_id}"18Graph`graph/nodes.py` — node should return updates, not mutate state
Even in the starter kit, it is useful to write nodes “adult” right away: read the state, called the internal component, returned the update. This facilitates replay, persistence and testing.
pythonasync def agent_node(state: AgentState, runtime):
result = await inner_agent.ainvoke(
{"messages": state["messages"]},
context=runtime.context,
)
return {
"messages": result["messages"],
"answer": result.get("structured_response"),
}19Graph`graph/routes.py` - routing is best taken separately
Starter kit routing logic should also be isolated. If you need a review, go to the review; if not, finish. This small function gives a lot of order when the graph starts to grow.
sqlfrom langgraph.graph import END
def route_after_agent(state: AgentState):
if state.get("review_required"):
return "review"
return END20Graph'graph/build.py' should only do wiring and 'compile'
This is where you collect state schema, nodes, routes and persistence. The cleaner the file, the easier it is to replace models, tools and nodes without breaking the architecture. In starter kit, it’s helpful to discipline yourself with this rule.
pythondef build_graph(settings, checkpointer, store):
builder = StateGraph(AgentState)
builder.add_node("agent", agent_node)
builder.add_node("review", review_node)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", route_after_agent)
builder.add_edge("review", END)
return builder.compile(checkpointer=checkpointer, store=store)Service Layer and Public API
21API‘services/chat service.py’ is where HTTP becomes graph invocation
The service layer is designed to keep routers unaware of the 'RunnableConfig', 'thread id', 'Command(resume=...)' and shape of the final answer. This location links external request/response schemas and internal runtime.
pythonclass ChatService:
def __init__(self, graph, settings):
self.graph = graph
self.settings = settings
async def chat(self, req: ChatRequest, ctx: RequestContext):
config = {"configurable": {"thread_id": req.thread_id or new_thread_id()}}
result = await self.graph.ainvoke(
{"messages": [{"role": "user", "content": req.message}]},
config=config,
context=ctx,
)
return to_chat_response(result, config)22API`stream service.py` is a separate layer for `astream` and `StreamingResponse`
Streaming is better not to hide inside the usual chat service. A separate service helps neatly form a generator, collect several ‘stream mode’ and decide how to code out tokens, updates and interrupts.
pythonasync for item in graph.astream(
inputs,
config=config,
context=ctx,
stream_mode=["messages", "updates"],
):
yield encode_sse(item)23API'api/routers/chat.py' - three endpoints for starter kit
The minimum set of public APIs for this service is very specific: "POST /v1/chat", "POST /v1/chat/stream", "POST /v1/threads/{thread id}/resume". That’s enough for a frontend, another service, or CLI to live with.
textAPI SURFACE
POST /v1/chat
POST /v1/chat/stream
POST /v1/threads/{thread_id}/resume
GET /healthz
GET /readyz24API*contracts/schemas.py should describe both requests and interrupt payload
A common mistake is to type only the usual answer. But if you have a human review, you also have an interrupt contract. It is also better to formalize a separate scheme: pause type, message, action schema, resume payload.
pythonclass ChatRequest(BaseModel):
thread_id: str | None = None
message: str
class ResumeRequest(BaseModel):
payload: dict
class InterruptPayload(BaseModel):
type: str
message: str
data: dict25API'health.py' is useful to do right away and 'readyz' to check real readiness
‘healthz’ can simply say ‘the process is alive’. “readyz” is more useful to do a little smarter: check that the service object is collected, persistence is available and basic dependencies have risen. For starter kit, a simple but honest readiness check is enough.
difference
- healthz = process alive
- readyz = can serve traffic
Persistence, Docker and Life Outside
26Deployinfra/persistence.py – one builder for checkpointer and store
Even if you use in-memory mode in local dev, it is useful to start a single module in the starter kit that can collect persistence resources. Then the path to durable execution in staging/prod remains direct, not chaotic.
pythonasync def build_persistence(settings):
if settings.environment == "local":
return in_memory_checkpointer(), in_memory_store()
return durable_checkpointer(settings), durable_store(settings)27DeployIf you have containers, it is often better to have one process per container.
FastAPI docs are a distinct reminder: in orchestrators like Kubernetes, one Uvicorn process per container and replica scaling is often preferred over a complex multiprocess inside. This simplifies memory model and lifecycle applications.
simple practice
- 1 process / container
- scale via replicas
- More workers are needed outside of container orchestration
28DeployDockerfile for starter kit should be boring and predictable
You don't need platform art. More important is a fast, clear image that puts dependencies, copies the code and runs Uvicorn. Difficulty can always be added, but the chaos in the container from the beginning only gets in the way.
sqlFROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install .
COPY app ./app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]29DeployStarter kit should already experience 'thread id' across restarts
If the old 'thread id' doesn't resolve after the restart, you don't have a service yet, you have a demo. Even a minimal production-oriented build should be built so that the conversation state can continue after the process is upgraded or dropped.
ripeness
- resume
- shared persistence
- state only in RAM
30DeployStarter kit: HTTP, service, graph, interrupt
The minimum working set of tests should cover several layers. API tests verify contracts. Service tests check the conversion of HTTP payload to runtime call. Graph tests test routing and persistent behavior. A separate test is required for “interrupt/resume”.
textTEST SET
test_chat_route.py
test_resume_route.py
test_chat_service.py
test_graph_flow.py
test_interrupt_resume.pyMCP and Where to Grow Next
31MCP*infra/mcp clients.py is a normal extension point for tools
If later you want to go to the MCP server instead of direct SDKs, it is more convenient to provide a separate module-adapter in advance. Then LangChain tools continue to look the same, and transport and protocol logic does not follow in graph code.
texttool
→ repository / adapter
→ mcp_client
→ remote MCP server32MCPwww.mcp server.py add when your service becomes a tool/data plane
You can add an MCP server if you want an IDE, external hosts, or other agents to use your capabilities. But this is the second interface of the product, not a mandatory part of the basic HTTP service.
pythonmcp = FastMCP("starter-service")
@mcp.tool
def search_kb(query: str) -> str:
...
@mcp.resource("thread://{thread_id}")
def get_thread(thread_id: str) -> str:
...33OutcomeThe evolution of starter kit usually looks like this.
First, you need to make a minimum durable service. Then you add better auth, observability, queue for long jobs, MCP adapters, richer tools, more complex graph, evals and rate limiting. But the foundation remains the same.
text1. basic chat / stream / resume
2. durable persistence
3. auth + readiness + structured logs
4. background jobs + queue
5. MCP client/server if needed
6. evals, guardrails, tracing, budgets34OutcomeFrequent anti-patterns starter kit
The most common problems are predictable: giant `main.py`, graph compilation for each request, state only in RAM, no resume endpoint, routes with business logic, tools with global singletons and zero tests on the path.
red flags
- all in one file
- No lifespan.
- There is no durable persistence
- No 'resume' API
35OutcomeThe main idea of the volume
A good starter kit for ‘FastAPI + LangGraph + LangChain’ is not a set of fun libraries, but a small backend with the right boundaries. If you have a ‘lifespan’, ‘app.state’, typed contracts, compiled graph, service layer, durable persistence and an honest ‘chat/stream/resume’ API, then you no longer have a demo, but the basis of a live external service.
textSummary formula
FastAPI
+ clean repo layout
+ LangChain components
+ LangGraph runtime
+ external persistence
+ chat/stream/resume contract
=
Starter kit that can be developed in production serviceNo dead end
Keep moving through the map.
Continue in sequence, switch to a related guide, or return to the seven-track learning map.