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.

01

What We Build

5 cards
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.

Diagramtext
START COMPLEX

public HTTP API

service layer

LangGraph runtime

LangChain components

external persistence + external tools
02Frame.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.

brieflytext
web process:
  preferably disposable and replaceable

conversation state:
  live outside the process
04Frame.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
02

File Starter Kit

5 cards
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.

Diagramtext
REPO 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.toml
07Starterpyproject.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.

minimumtext
dependencies = [
  "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”.

nameplatetext
HTTP:
  /v1/chat
  /v1/chat/stream
  /v1/threads/{thread_id}/resume

Python:
  ChatRequest
  ChatResponse
  ChatService
  build_graph
  RequestContext
  AgentState
10StarterStarter 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.
03

Core Files and Start of Service

5 cards
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.

examplepython
class 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.

skeletonsql
from 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.

examplepython
def 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.

formulatex
context = who started the run and what conditions
What happened inside the run?
04

Contracts, Components and Graph

5 cards
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.

examplesql
from 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: dict
17Graph`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.

toolpython
@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.

examplepython
async 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.

examplesql
from langgraph.graph import END

def route_after_agent(state: AgentState):
    if state.get("review_required"):
        return "review"
    return END
20Graph'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.

skeletonpython
def 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)
05

Service Layer and Public API

5 cards
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.

examplepython
class 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.

ideapython
async 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.

Diagramtext
API SURFACE

POST /v1/chat
POST /v1/chat/stream
POST /v1/threads/{thread_id}/resume
GET  /healthz
GET  /readyz
24API*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.

examplepython
class ChatRequest(BaseModel):
    thread_id: str | None = None
    message: str

class ResumeRequest(BaseModel):
    payload: dict

class InterruptPayload(BaseModel):
    type: str
    message: str
    data: dict
25API'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
06

Persistence, Docker and Life Outside

5 cards
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.

ideapython
async 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.

examplesql
FROM 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”.

Diagramtext
TEST SET

test_chat_route.py
test_resume_route.py
test_chat_service.py
test_graph_flow.py
test_interrupt_resume.py
07

MCP and Where to Grow Next

5 cards
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.

ideatext
tool
→ repository / adapter
→ mcp_client
→ remote MCP server
32MCPwww.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.

Minimal logicpython
mcp = 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.

growth-tracktext
1. 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, budgets
34OutcomeFrequent 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.

Diagramtext
Summary formula

FastAPI
  + clean repo layout
  + LangChain components
  + LangGraph runtime
  + external persistence
  + chat/stream/resume contract
  =
Starter kit that can be developed in production service

No dead end

Keep moving through the map.

Continue in sequence, switch to a related guide, or return to the seven-track learning map.