Deploy Forge: LangGraph Platform, the Server API, and Studio (LangGraph in Production, Module 14)
Deploy Forge: LangGraph Platform, the Server API, and Studio (LangGraph in Production, Module 14)
This is Module 14 of LangGraph in Production, a free 16-module course that takes you from your first StateGraph to a deployed, durable autonomous coding agent on LangGraph v1.x. Start at Module 1 or browse the full syllabus.
Your CI team wants Forge to pick up every new issue automatically — no Python import, no script to run, no clone of the repo required. Your ops lead wants to see in real time which thread is blocked on a plan-approval interrupt() without opening a terminal. And two issues arrive simultaneously on the same thread: which run wins, and what happens to the other?
Right now, the answer to all three questions is “impossible without changing the code.” After thirteen modules, Forge is a fully featured autonomous coding agent — persistent, durable, streamed, multi-agent, and human-checkpointed. But it runs inside a Python script on your machine. Every external caller must import the package and call build_graph().invoke(...) directly. There is no HTTP API, no concurrent-run management, no visual interface for debugging a stuck thread, and no auth to control who can trigger a run.
An agent that only runs on its author’s laptop is a prototype. LangGraph Platform (also known as LangSmith Deployment since October 2025 — both names are in active use and we will name both every time) turns Forge into a production HTTP service in minutes: one manifest file, one command, and an SDK.
In this module
You’ll learn:
- How the LangGraph deployment stack is organized into three distinct layers — OSS runtime, Agent Server, and LangGraph Platform / LangSmith Deployment — and when to choose each.
- How to write a
langgraph.jsonmanifest that describes Forge for deployment, field by field. - How to launch Forge with
langgraph devand navigate LangGraph Studio to inspect threads, time-travel, and resolve a stuckinterrupt(). - How to consume Forge from a Python SDK client:
get_client, assistants, threads, and streaming runs, plusRemoteGraphas the drop-in local-interface alternative. - How the four double-texting strategies work (
reject,enqueue,interrupt,rollback) and which are Platform-only. - How to wire webhooks and crons for fire-and-forget and scheduled runs.
- How to protect Forge with custom auth:
Auth,@auth.authenticate, and@auth.on.*handlers.
You’ll build: A deployment-ready Forge: a langgraph.json manifest, a running langgraph dev server with Studio, a Python SDK client (client_demo.py) that creates an assistant, opens a thread, and streams a run — plus forge/auth.py so only authorized callers can trigger Forge.
Concepts covered: CA14 — Deployment, observability & production (core). This module builds CA14.1–CA14.9 of the LangGraph theory inventory. CA14.10–CA14.15 (LangSmith tracing, evaluation, CI gates) are the primary focus of Module 15.
Prerequisites: Modules 1–13, the complete cumulative forge/ package (subgraphs + Supervisor + Functional API), langgraph-cli~=0.4 installed (uv add langgraph-cli~=0.4), and a .env with ANTHROPIC_API_KEY. For $0, set FORGE_MODEL_PROVIDER=ollama in .env — no other changes needed.
Where you are
M1–M13 ✅ → M14 👉 → M15 ⬜ → M16 ⬜
Before this module: Forge is a complete autonomous coding agent — persistent, durable, streamed, multi-agent. It runs locally via .invoke() and .stream() on the developer’s machine. There is no HTTP API, no Studio, no external callers.
After this module: Forge runs as an HTTP service. Any caller — a CI bot, a Slack integration, a parent agent — can reach it via REST. Studio gives you a visual debugger. SDK clients stream runs with one function call.
What remains: LangSmith tracing and the golden-set evaluation (M15); the production readiness checklist, mastery review, and my real deployment debrief (M16).
Three Layers: OSS, Agent Server, and LangGraph Platform
Before you touch a single file, you need a clear mental model of the deployment stack. Confusing these three layers is the single most common source of production errors with LangGraph.
Layer 1 — OSS langgraph (MIT)
This is the pure Pregel runtime. Everything you have built since Module 1 lives here: StateGraph, .compile(checkpointer=...), .invoke(), .stream(), interrupt(), Send, Command, durability=. It is free, open source, and runs anywhere Python runs. It carries no LangChain account requirement.
When you call build_graph() and invoke it in a script, you are using only this layer. The OSS runtime is complete and production-capable on its own — you can serve it behind your own FastAPI or Flask application if you want full control.
Layer 2 — Agent Server (langgraph-api)
The Agent Server is an HTTP layer that wraps the OSS runtime and exposes a REST API organized around three resources: assistants, threads, and runs (plus crons and a Store endpoint). It handles connection management, streaming over SSE, and persistence via Postgres + Redis in production.
This is what langgraph dev (in-memory, hot-reload) and langgraph up (Docker, Postgres + Redis) run. The SDK (langgraph-sdk) speaks to this layer. Critically, the Agent Server can be self-hosted without a LangChain account — the langgraph-api image is deployable on your own infrastructure.
Layer 3 — LangGraph Platform / LangSmith Deployment
This is the managed cloud layer — control plane hosted by LangChain, cloud deployments, scaling, monitoring dashboards. It wraps the Agent Server with infrastructure you do not have to run yourself.
Important rename (October 2025): “LangGraph Platform” (the historical name) and “LangSmith Deployment” (the name now used in the smith.langchain.com UI) refer to the same product. The docs use both interchangeably, and so will this module. Do not let either name confuse you into thinking there are two separate products — there is one managed offering, and it got a new name.
Capabilities that are exclusive to this layer (not available in the self-hosted Agent Server without a plan): double-texting strategies beyond reject, webhooks on run completion, and managed crons. A free tier exists (as of June 2026 — verify at signup).
Choosing the right layer
graph TD
A["OSS langgraph (MIT, free)\nbuild_graph() · .invoke() · .stream()\nRuns anywhere Python runs"] --> B["Agent Server (langgraph-api)\nassistants · threads · runs · crons · Store\nPostgres + Redis for persistence\nlanggraph dev (in-memory) / langgraph up (Docker)"]
B --> C["LangGraph Platform\na.k.a. LangSmith Deployment\n(managed cloud — renamed Oct. 2025)\ndouble-texting · webhooks · managed crons\nsmith.langchain.com/deployments"]
| Goal | Tool |
|---|---|
| Local dev and debugging with Studio | langgraph dev (Layer 2, in-memory) |
| Self-hosted production, full control | langgraph up (Docker + Postgres + Redis) |
| Managed cloud, no ops overhead | LangGraph Platform / LangSmith Deployment |
In this module, Forge runs on langgraph dev (Layer 2, in-memory). The API surface is identical to what you would get in production — the only difference is that in-memory state is lost when the server restarts, and some double-texting strategies require the full Agent Server with Postgres.
⚠️ Common misconception: “LangGraph Platform and LangSmith Deployment are two different products.”
They are the same product. “LangGraph Platform” was the original name; “LangSmith Deployment” is what LangChain now calls it in the
smith.langchain.comUI (since October 2025). Both names appear in the official docs. This module always names both at first mention.A related confusion: thinking that self-hosting the Agent Server (Layer 2) requires a LangChain account. It does not —
langgraph-apiis self-deployable. The managed-only features (advanced double-texting strategies, webhooks, managed crons) require a LangGraph Platform / LangSmith Deployment plan.
langgraph.json: the Deployment Manifest
The langgraph.json file is the single entry point for langgraph dev, langgraph build, and all managed deployments. Think of it as a Dockerfile for your LangGraph agent: it describes what to deploy and where to find it, not how to run infrastructure.
Here is the real langgraph.json from the module-14 lab:
{
"$schema": "https://langgra.ph/schema.json",
"dependencies": ["."],
"graphs": {
"forge": "./forge/graph.py:build_graph"
},
"env": ".env",
"python_version": "3.12",
"auth": {
"path": "./forge/auth.py:auth"
}
}
Let’s go through every field:
dependencies — ["."] means “install the current directory as a package.” The server reads your pyproject.toml, resolves the dependency tree, and installs everything into its environment. You can add extra PyPI packages here as strings alongside ".".
graphs — A map of graph IDs to import paths. The format is "graph-id": "./path/to/file.py:symbol". The symbol can be either:
- A compiled graph variable:
graph = build_graph()at module level (the server importsgraphdirectly). - A factory function:
build_graph— a zero-argument callable that returns a compiled graph. This is what the lab uses:"./forge/graph.py:build_graph".
Both work. The graph ID ("forge") becomes the graph_id you pass to the SDK when creating an assistant.
env — Path to your .env file. The server loads it at startup. Your ANTHROPIC_API_KEY and FORGE_MODEL_PROVIDER live here.
python_version — "3.12" matches the course pin. Used by langgraph build to select the base image.
auth — Points to your Auth object (covered in the auth section below). When present, the server calls @auth.authenticate on every incoming request. Omit this field for unauthenticated access during early development.
Fields not used here but worth knowing:
store— Configure a server-managed Store (the semantic memory from Module 10). Inlanggraph devit is in-memory; inlanggraph upor Platform, it can be backed by Postgres with a vector index.http— Advanced HTTP config (CORS origins, custom middleware).checkpointer— If absent (the default), the Agent Server manages its own checkpointer. Do not configure this unless you have a very specific reason.
The checkpointer trap
This is the single most common production mistake with langgraph.json.
In Modules 6 through 13, build_graph() accepted a checkpointer argument and you passed a SqliteSaver. In the lab, forge/graph.py defines:
def build_graph(checkpointer=None, store=None):
builder = StateGraph(ForgeState)
# ... add nodes and edges ...
return builder.compile(
checkpointer=checkpointer,
store=store,
cache=InMemoryCache(),
)
The checkpointer=None default is not an oversight — it is intentional. When the Agent Server imports this factory, it injects its own checkpointer (in-memory for langgraph dev, Postgres for langgraph up). If you hard-code SqliteSaver inside build_graph() without making it optional, the server cannot substitute its own persistence layer and will fail to start.
Rule: always accept checkpointer as an optional parameter with a None default. The local script passes a SqliteSaver; the server passes its own. Same factory, two deployment modes.
langgraph dev and LangGraph Studio
With langgraph.json in place, starting the development server is one command:
# Start the in-memory server with hot-reload and Studio
uv run langgraph dev
# With a custom port or config path
uv run langgraph dev --port 8123 --config langgraph.json
What happens when you run this:
- The CLI parses
langgraph.json, importsbuild_graphfromforge/graph.py, and calls it to compile the graph. - It imports
authfromforge/auth.pyand registers it as the authentication layer. - It starts an HTTP server on
localhost:2024(the default port) — no Docker, no external services. - It prints a Studio URL:
https://smith.langchain.com/studio?baseUrl=http://localhost:2024. - It watches
forge/for changes and hot-reloads the graph automatically.
LangGraph Studio
LangGraph Studio is the visual debugger for LangGraph agents — there is no other tool like it in the ecosystem. Open the Studio URL in a browser while langgraph dev is running and you get:
Graph view. Forge’s full StateGraph rendered as a node-edge diagram. The subgraphs you added in Module 12 (Planner, Coder, Reviewer) are visible and expandable. You can see the entire architecture you have been building — intake → triage → recall → planner → fan-out → tester → reviewer → pr_approval — laid out visually.
Thread state inspector. Click on any thread to see the full ForgeState at any checkpoint: issue, plan, edits, test_result, review, pull_request — every field. No print() statements, no Python console.
Time-travel. You built time-travel in Module 8 using get_state_history and update_state from the Python API. Studio makes the same capability clickable: browse the checkpoint history of any thread, select a past checkpoint, edit the state, and re-run from there. The concept is identical to what you coded in time_travel_demo.py — Studio is just the UI for it.
Interrupt resolution. When Forge is blocked on interrupt() — plan approval or PR approval (Module 7) — Studio shows you the interrupt payload and lets you respond directly from the UI. This is exactly the interface the ops lead from the opening scenario wanted: a visual dashboard showing which threads are blocked and why, with the ability to approve or reject without touching code.
Run logs. Every run event, node execution, token, and error is logged and browsable.
The full CLI toolkit
langgraph dev is not the only command. Here is the complete picture:
| Command | What it does | When to use |
|---|---|---|
langgraph dev | In-memory server, hot-reload, Studio | Local development and debugging |
langgraph build -t my-image | Builds a Docker image of the Agent Server | Preparing for self-hosted production |
langgraph up | Runs the Docker image with Postgres + Redis | Self-hosted production |
langgraph dockerfile | Generates a customizable Dockerfile | When you need to extend the base image |
The lab in this module uses langgraph dev. The jump from langgraph dev to langgraph up is mostly infrastructure — same API, same langgraph.json, same SDK client code, different persistence backend.
graph LR
A["langgraph.json\n(manifest)"] --> B["langgraph dev\n(Agent Server, in-memory)\nlocalhost:2024"]
B --> C["LangGraph Studio\nsmith.langchain.com/studio\n— graph view\n— thread inspector\n— time-travel\n— interrupt UI"]
B --> D["REST API :2024\n/assistants\n/threads\n/runs"]
D --> E["SDK client\nclient_demo.py\nget_client / RemoteGraph"]
Assistants, Threads, and Runs: the Server’s Resource Model
The Agent Server exposes three first-class resources. Understanding them before you write SDK code makes the API obvious rather than arbitrary.
Assistant
An assistant is a versioned configuration of a graph. When you create an assistant, you tell the server which graph_id to use (the key from langgraph.json’s graphs dict — "forge" in our case). You can add metadata, configuration overrides, and update an assistant to create a new version.
A single graph can have multiple assistants with different configurations — for example, a "forge-fast" assistant that uses cheaper models for all nodes and a "forge-thorough" assistant that uses the smart model everywhere. The graph code does not change; the assistant captures the config.
Thread
A thread is a state container — the same concept as the thread_id you passed to SqliteSaver in Module 6, but managed server-side. A thread holds the complete ForgeState history across all runs that execute on it. Create a thread, run an assistant on it, run again, and the second run picks up exactly where the first left off.
Run
A run is one execution of an assistant on a thread with a given input. Runs can be:
- Streaming: results arrive chunk by chunk over SSE (what
client.runs.stream(...)gives you). - Blocking: waits for the run to complete and returns the final state (what
client.runs.create(...)withawaitdoes).
Runs carry metadata, config, stream_mode, and — crucially — multitask_strategy for double-texting (covered next).
SDK client: get_client
The langgraph-sdk package (still at version 0.x as of June 2026 — langgraph-sdk~=0.4) provides the async Python client. Here is the complete client_demo.py from the lab:
"""Drive a deployed Forge over the Server API (Module 14).
Run `uv run langgraph dev` first, then `uv run python client_demo.py`.
"""
from __future__ import annotations
import asyncio
from langgraph_sdk import get_client
async def run(url: str = "http://localhost:2024") -> None:
client = get_client(url=url)
# 1. Create an assistant for the "forge" graph
assistant = await client.assistants.create(graph_id="forge")
# 2. Open a new thread (a Forge work session)
thread = await client.threads.create()
# 3. Stream a run — updates mode emits one chunk per node
async for chunk in client.runs.stream(
thread["thread_id"],
assistant["assistant_id"],
input={"issue": "tally report --month crashes on empty category",
"repo_path": "/tmp/tally"},
stream_mode="updates",
multitask_strategy="enqueue",
):
print(chunk.event, chunk.data)
if __name__ == "__main__":
asyncio.run(run())
get_client returns an async client bound to the server URL. In production against LangGraph Platform / LangSmith Deployment, you pass the deployment URL and your LANGSMITH_API_KEY as a header — the API surface is identical.
stream_mode="updates" delivers one chunk per node as it finishes: you get {"triage": {"route": "bug"}}, then {"planner": {"plan": {...}}}, then {"editor": {...}} — the same information as streaming mode updates from a local graph.
RemoteGraph: the drop-in local interface
RemoteGraph (from langgraph.pregel.remote) wraps a deployed graph and exposes the same .invoke() / .stream() / .ainvoke() / .astream() interface as a locally compiled graph. You can pass a RemoteGraph anywhere a compiled graph is expected — including as a node in another LangGraph graph:
from langgraph.pregel.remote import RemoteGraph
# RemoteGraph exposes the same interface as build_graph()
remote_forge = RemoteGraph("forge", url="http://localhost:2024")
# Use it exactly like a local graph
result = await remote_forge.ainvoke(
{"issue": "Feature: add --csv export flag to tally report",
"repo_path": "/tmp/tally"},
config={"configurable": {"thread_id": "ci-run-42"}},
)
# Or stream — identical to build_graph().astream()
async for event in remote_forge.astream(
{"issue": "Bug: storage.py ignores timezone in add_expense"},
stream_mode=["updates", "messages"],
):
print(event)
This is the connection to Module 12’s multi-agent work (CA12.7). An orchestrator graph can embed RemoteGraph("forge", url=...) as a node and call Forge as if it were a local subgraph — no protocol changes, no adapter code. The Forge deployment is transparent to the parent graph.
When to prefer RemoteGraph over the low-level SDK:
- When existing code expects a LangGraph
Runnable(e.g., a parent graph node, a chain, a streaming loop you wrote for local use). - When you want the same calling convention for local testing (using
build_graph()) and deployed use (usingRemoteGraph).
When to prefer the low-level SDK (client.runs.stream, client.assistants, etc.):
- When you need fine-grained control over metadata, run configuration, or assistant versioning.
- When you need to set webhooks or multitask strategies.
- When you are building a management UI or a CI integration that needs to list threads, check run status, or delete resources.
Double-Texting, Webhooks, Crons, and Custom Auth
Double-texting
Double-texting is what happens when a second input arrives while a run is already active on a thread. By default, the server rejects it with a 409. But you can override this with the multitask_strategy parameter on client.runs.stream(...) or client.runs.create(...):
| Strategy | Behavior | Best for Forge |
|---|---|---|
reject (default) | The second run is rejected immediately with 409 | Strict HITL: never process two issues in parallel on the same thread |
enqueue | The second run waits in a queue until the first completes | CI burst: issues arrive in batches; process sequentially, lose none |
interrupt | The current run is interrupted cleanly; the new run starts | Interactive session: the latest instruction wins |
rollback | The current run and its checkpoints are rolled back; the new run starts from the last clean checkpoint | Correcting a bad input before the run finishes |
These strategies are Platform-only. In langgraph dev (in-memory), only reject is reliable. The enqueue, interrupt, and rollback strategies require the Agent Server with Postgres to manage the run queue and checkpoint rollback durably. They work as expected on LangGraph Platform / LangSmith Deployment (managed) and on a self-hosted langgraph up deployment with Postgres + Redis.
For a CI integration sending bursts of Forge runs, enqueue is the right default — no issue is lost, and they are processed in order. For an interactive Slack bot where a user corrects a bad issue description mid-run, interrupt gives the best user experience.
Webhooks
A webhook delivers the run result to an HTTP endpoint when the run completes (success or error):
await client.runs.create(
thread["thread_id"],
assistant["assistant_id"],
input={"issue": "Bug: report.py crashes with ZeroDivisionError"},
webhook="https://ci.example.com/forge/callback",
)
The server POSTs the final state as JSON to your webhook URL when the run finishes. For Forge: notify the CI pipeline that a PR was opened, trigger an automated review, or update a project board. This is a fire-and-forget pattern — the caller does not wait for the run. Webhooks are Platform-only.
Crons
A cron schedules runs on a time-based schedule. Each cron tick creates a new thread and launches a run:
await client.crons.create(
assistant["assistant_id"],
schedule="0 9 * * 1-5", # weekdays at 9 AM
input={"issue": "Routine: run test suite health check on Tally"},
)
For Forge: a scheduled health check that runs the test suite on Tally every weekday morning, or a weekly dependency audit. Crons are Platform-only.
Stateless runs
A run without a thread_id does not persist state between calls. Pass thread_id=None or omit it entirely for one-shot tasks where you do not need checkpointing, time-travel, or HITL:
# No thread — state is discarded when the run ends
async for chunk in client.runs.stream(
None, # stateless
assistant["assistant_id"],
input={"issue": "Quick check: does Tally parse --month '2026-06' correctly?"},
stream_mode="values",
):
print(chunk.data)
Custom auth
Forge executes arbitrary code on a sandbox copy of Tally. You cannot let unauthenticated callers trigger runs. The Auth class (from langgraph_sdk, not from langgraph core) gives you two hooks: @auth.authenticate validates every incoming request, and @auth.on.* handlers apply fine-grained authorization rules per resource and operation.
Here is the real forge/auth.py from the lab:
"""Custom auth for Forge (Module 14).
Wired via langgraph.json -> "auth": {"path": "./forge/auth.py:auth"}.
The Server calls @auth.authenticate on every request, then @auth.on.*
handlers scope resources to the user.
"""
from __future__ import annotations
from langgraph_sdk import Auth
# Demo token store. Replace with real JWT/IdP validation in production.
_TOKENS = {"sk-forge-demo": "user-1"}
def verify_token(authorization: str | None) -> str | None:
"""Map a Bearer token to a user id. Returns None if missing/invalid."""
if not authorization:
return None
token = authorization.removeprefix("Bearer ").strip()
return _TOKENS.get(token)
auth = Auth()
@auth.authenticate
async def authenticate(authorization: str | None = None):
user = verify_token(authorization)
if user is None:
raise Auth.exceptions.HTTPException(status_code=401, detail="Unauthorized")
return {"identity": user}
@auth.on.threads.create
async def on_thread_create(ctx, value):
"""Stamp ownership so a user only sees their own threads."""
value.setdefault("metadata", {})["owner"] = ctx.user.identity
How it works:
-
@auth.authenticateruns on every request — before routing, before resource checks. It receives the rawAuthorizationheader (via theauthorizationparameter name, which the SDK maps automatically), validates the token, and returns aMinimalUserDictwith at minimum an"identity"key. If validation fails, raiseAuth.exceptions.HTTPExceptionwith the appropriate status code. -
@auth.on.threads.createis a resource-level handler. It fires after authentication succeeds, specifically when a thread is about to be created. Here it stamps the thread’s metadata with the owner’s identity — useful for later ownership checks. -
@auth.on.runs.create(shown in the brief’s design spec, not in the minimal lab code) would fire before a run is created, letting you check whether the caller has"write"permissions before Forge executes. Add it when you have a permissions model.
The handler chain is: authenticate (global, every request) → @auth.on.<resource>.<operation> (targeted, per resource type). This lets you have one authentication mechanism (token validation) with many authorization rules (who can create threads, who can read state, who can create runs on a specific thread).
In production, replace the _TOKENS dict with a JWT validation function that calls your identity provider. The function signature and return shape stay the same — that is the whole point of the plugin design.
Security in self-host (advanced)
If you run langgraph up on your own infrastructure:
- Never expose the Agent Server port directly to the internet without a reverse proxy (nginx, Caddy) with TLS.
- All secrets (
ANTHROPIC_API_KEY, auth tokens) go in environment variables or a secrets manager, not inlanggraph.jsonor committed config. - Forge executes code — the sandbox from Module 9 (
forge/sandbox.py: tempdir copy, isolated subprocess) remains your RCE defense. The auth layer controls who can trigger runs; the sandbox controls what those runs can do. - Limit container network access (no outbound internet from the sandbox subprocess unless the feature explicitly needs it). Apply principle of least privilege to the IAM role or service account the container runs under.
This is the first pass at CA14.15 (security hardening). The complete production checklist — including network policy, secrets rotation, and incident response — is Module 16.
Hands-on: Build It
Goal: Get Forge running as an HTTP service accessible from client_demo.py and LangGraph Studio, with token-based auth preventing unauthorized runs.
What you’ll see at the end:
# Terminal 1 — start the server
$ uv run langgraph dev
Starting LangGraph API server...
Ready on http://localhost:2024
Studio: https://smith.langchain.com/studio?baseUrl=http://localhost:2024
# Terminal 2 — run the offline test suite (no API key needed)
$ uv run pytest tests/ -q
...
16 passed in 0.84s
# Terminal 2 — run the live client demo (needs ANTHROPIC_API_KEY)
$ uv run python client_demo.py
updates {'triage': {'route': 'bug'}}
updates {'planner': {'plan': {'summary': 'Fix ZeroDivisionError ...'}}}
updates {'editor': {'edits': [...]}}
updates {'tester': {'test_result': {'passed': True, 'total': 12}}}
updates {'reviewer': {'review': {'approved': True, 'comments': []}}}
Step 1: Install the CLI and SDK
Add two packages to pyproject.toml (the CLI and SDK are still at 0.x as of June 2026):
[project]
dependencies = [
"langgraph~=1.2",
"langchain~=1.3",
"langchain-core~=1.4",
"langchain-anthropic~=1.4",
"langgraph-cli~=0.4", # NEW — langgraph dev / build / up
"langgraph-sdk~=0.4", # NEW — get_client / RemoteGraph
"langgraph-checkpoint-sqlite~=3.1",
]
Run uv sync to update the lockfile. The exact versions as of June 2026: langgraph-cli 0.4.29, langgraph-sdk 0.4.2.
Step 2: Verify forge/graph.py accepts optional checkpointer
The lab’s build_graph already accepts checkpointer=None, store=None. Confirm the signature and the compile call:
def build_graph(checkpointer=None, store=None):
builder = StateGraph(ForgeState)
# ... nodes and edges (unchanged from M13) ...
return builder.compile(
checkpointer=checkpointer,
store=store,
cache=InMemoryCache(),
)
If your version hard-codes a SqliteSaver inside build_graph, make it optional now. The server injects its own checkpointer; your local scripts still pass their own.
Step 3: Write langgraph.json
Create langgraph.json at the module root (same directory as pyproject.toml):
{
"$schema": "https://langgra.ph/schema.json",
"dependencies": ["."],
"graphs": {
"forge": "./forge/graph.py:build_graph"
},
"env": ".env",
"python_version": "3.12",
"auth": {
"path": "./forge/auth.py:auth"
}
}
"forge": "./forge/graph.py:build_graph" — the server imports build_graph from forge/graph.py and calls it (it is a factory). The key "forge" becomes the graph_id you pass to the SDK.
Step 4: Write forge/auth.py
Create forge/auth.py with the code shown in the auth section above. For the lab, the token "sk-forge-demo" is hardcoded — this is enough to demonstrate the mechanism. Callers must send Authorization: Bearer sk-forge-demo.
Step 5: Start the server and explore Studio
uv run langgraph dev
Open the Studio URL in your browser. You should see:
- Graph view — Forge’s full StateGraph. The intake → triage → recall → planner → plan_approval chain is visible. Click on the
editornode — you should see the fan-out edges toapply. - Empty thread list — no threads yet. Good.
Leave the server running. Open a second terminal for the next steps.
Step 6: Run the offline test suite
The three tests in tests/test_module_14.py verify the deployment artifacts without needing a live server:
# tests/test_module_14.py (excerpt — the full file is in the lab repo)
def test_langgraph_json_valid_and_graph_factory_resolves():
cfg = json.loads((_module_dir() / "langgraph.json").read_text())
assert cfg["dependencies"] == ["."]
assert cfg["graphs"]["forge"] == "./forge/graph.py:build_graph"
assert cfg["auth"]["path"] == "./forge/auth.py:auth"
from forge.graph import build_graph
assert build_graph() is not None
def test_auth_token_verification():
from forge.auth import auth, verify_token
assert verify_token("Bearer sk-forge-demo") == "user-1"
assert verify_token("Bearer wrong") is None
assert verify_token(None) is None
def test_sdk_client_and_remote_graph_importable():
from langgraph.pregel.remote import RemoteGraph
from langgraph_sdk import get_client
assert callable(get_client) and RemoteGraph is not None
These run without a server, without an API key, and without any network calls. uv run pytest tests/ -q should show all tests green.
Step 7: Run the live client demo
With langgraph dev still running in Terminal 1:
# In Terminal 2
uv run python client_demo.py
You should see streaming updates from each node as Forge processes the issue. Switch to the Studio tab in your browser — the thread you just created is now visible in the thread list. Click on it to see ForgeState evolve in real time (or after the run completes, if it finished fast).
Step 8: Explore the interrupt flow in Studio
For this step, you need an issue that will trigger the plan-approval interrupt(). Any realistic Tally issue will do. When the run hits interrupt() in plan_approval, Studio shows a banner with the interrupt payload. Click “Resume” and provide {"approved": true} (or use the form if Studio renders it) — the run continues from where it stopped. This is the HITL workflow from Module 7, now accessible without a Python console.
Step 9: Verify the auth layer
Send an unauthorized request to confirm auth works:
# No auth header — should return 401
curl -s -o /dev/null -w "%{http_code}" \
http://localhost:2024/assistants
# With the demo token — should return 200
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer sk-forge-demo" \
http://localhost:2024/assistants
The server validates every request through @auth.authenticate before any other handling.
Try it yourself
-
Extend the auth policy. Add
@auth.on.threads.readtoauth.pyso that a user can only read threads they own (wheremetadata["owner"] == ctx.user.identity). Verify that a second token cannot read threads created by the first. -
Add a second assistant with a different config. The
client.assistants.createcall accepts aconfigdict — pass{"configurable": {"model_tier": "fast"}}and wirecontext_schemain the graph to pick it up. Name this assistant"forge-fast"and run it side-by-side with the default one. -
Demonstrate double-texting (observe, do not fix). Launch a run with a slow issue and immediately launch a second run on the same thread with
multitask_strategy="reject"(the default). The second call should return 409. Change it to"enqueue"— note the behavior inlanggraph dev(it may or may not work reliably; document what you observe).
In Production
langgraph dev is comfortable but does not reflect production conditions. Here is what changes when you go to langgraph up (self-hosted Docker) or LangGraph Platform / LangSmith Deployment (managed):
Persistence is durable. In langgraph dev, all thread state lives in memory — a server restart loses every thread. In langgraph up, thread state, checkpoints, and the Store are backed by Postgres. In Platform / LangSmith Deployment, the same, with LangChain managing the database. This is the difference between a demo and a real product: your users’ Forge runs survive infrastructure events.
Streaming uses Pub/Sub. In langgraph dev, streaming works because the client and server are in the same process (or the same machine). In a multi-instance deployment, streaming events are fanned out via Redis Pub/Sub. If you run langgraph up without Redis, streaming clients on a different instance from the running graph will get stale data.
Double-texting strategies require Postgres. enqueue, interrupt, and rollback need durable run queues and the ability to roll back checkpoints atomically. These operations are not safe on an in-memory store. In practice: use reject with langgraph dev; use enqueue in production for CI batch workloads, interrupt for interactive user sessions.
Auth goes real. The demo _TOKENS dict in forge/auth.py is a placeholder. In production, @auth.authenticate should call your JWT validation library or your identity provider’s token introspection endpoint. The function signature does not change — just the body. Plan for token expiry, refresh flows, and service account tokens for CI callers.
Cost per run. Forge uses get_model("smart") for the Planner, Editor, and Reviewer, and get_model("fast") for intake and triage. A single Forge run on a moderate Tally issue costs roughly $0.15–0.25 at June 2026 Claude prices. In a CI pipeline that runs Forge on every pull request, this adds up. The config.py tier system is your cost lever: switch the Editor to "fast" for a cheaper but slightly less thorough edit pass. Module 15 covers cost tracking properly with LangSmith.
LangSmith tracing and evaluation are the next layer, and they are completely separate from the Server API. You can enable LangSmith tracing on a deployed Forge by adding LANGSMITH_TRACING=true and LANGSMITH_API_KEY to .env — without changing a single line of code. Module 15 covers this in full, including a golden test set of 10 Tally issues and an LLM-as-judge evaluator.
Mastery Corner
What to really understand here
CA14.1 — Three layers, not one. The OSS runtime, the Agent Server, and the managed Platform are genuinely separate. langgraph dev gives you Layer 2 (Agent Server) locally, in-memory. The same JSON manifest and the same SDK work against all three.
CA14.2 — langgraph.json is declarative, not procedural. You declare what to deploy (the graph factory, the env file, the auth object, the dependencies). The server figures out how to run it. The graph factory must accept checkpointer=None so the server can inject its own.
CA14.3/14.4 — langgraph dev + Studio are a debugging workflow. Studio is not a production dashboard — it is a development tool that happens to also work on a deployed server. Its real value is the interrupt resolution UI (M7 HITL made visual) and the time-travel inspector (M8 debugging made clickable).
CA14.5/14.6 — Assistants, threads, runs. An assistant is a graph config. A thread is a state container. A run is one execution. RemoteGraph exposes the compiled-graph interface and belongs in any code that already expects a Runnable.
CA14.7 — Double-texting strategies are Platform-only beyond reject. In langgraph dev, treat reject as the only reliable strategy. enqueue is what you want for CI burst workloads, but it requires Postgres.
CA14.8/14.9 — Webhooks, crons, auth complete the production picture. Webhooks decouple callers from long-running runs. Crons schedule proactive agent work. Auth is not optional when your agent executes code.
Five mastery questions
Q1. Your team wants to use Forge locally with Studio for debugging interrupted runs. No Docker, no LangChain account. Which command starts the server and which layer of the deployment stack does it use?
A) langgraph up — Agent Server with Postgres
B) langgraph dev — Agent Server in-memory
C) langgraph build — Docker image build, then run with Python
D) langgraph studio — managed Studio only, requires a LangChain account
Q2. Your langgraph.json has "graphs": {"forge": "./forge/graph.py:build_graph"}. The server starts but immediately raises: “Symbol ‘build_graph’ is not a compiled graph.” What is the most likely cause?
A) The build_graph function is not exported at module level
B) build_graph returns a StateGraph builder instead of calling .compile() on it
C) The dependencies field is missing from langgraph.json
D) forge/graph.py imports SqliteSaver which conflicts with the server checkpointer
Q3. A CI pipeline sends up to 10 Forge issues in rapid succession to the same thread. With multitask_strategy="reject" (the default), 9 out of 10 runs fail with 409. Which strategy guarantees that all 10 runs are processed sequentially? Is it available in langgraph dev?
A) interrupt — available in langgraph dev
B) rollback — available in langgraph dev
C) enqueue — Platform-only (requires Postgres-backed Agent Server)
D) enqueue — fully available in langgraph dev in-memory
Q4. Your security requirement: only the user who created a thread can start new runs on it. Which auth handlers do you need, and what does each do?
A) Only @auth.authenticate — it handles both identity and resource-level authorization in one function
B) @auth.authenticate to validate the token and return user identity; @auth.on.runs.create to check that the run’s thread is owned by the caller
C) @auth.on.threads.create alone — it prevents other users from creating threads on your behalf
D) No auth handlers needed — the server automatically scopes runs to thread owners
Q5. You have code that streams Forge using build_graph().astream(...). You deploy Forge to langgraph dev. How do you change the calling code to stream against the deployed server with the same interface?
A) Replace build_graph() with get_client(url=...).runs.stream(...) — you must rewrite all stream handling
B) Replace build_graph() with RemoteGraph("forge", url=...) — the .astream() call is identical
C) No change needed — build_graph() automatically routes to the server when LANGGRAPH_SERVER_URL is set
D) Replace build_graph() with get_sync_client(url=...) and use .stream() instead of .astream()
Answers
A1: B. langgraph dev starts the Agent Server in-memory (Layer 2) with Studio on localhost:2024. No Docker, no LangChain account required. langgraph up (A) requires Docker. langgraph build (C) only builds an image. There is no langgraph studio standalone command (D) — Studio is accessed via the URL the dev server prints.
A2: B. If build_graph returns the StateGraph builder object (forgetting .compile()) instead of the compiled graph, the server cannot use it as a graph. A factory symbol must return a CompiledStateGraph. The function being at module level (A) is fine — build_graph is exported. A missing dependencies field (C) would prevent the package from being installed, not cause this specific error. Hard-coding SqliteSaver (D) causes a different problem (checkpointer injection failure) but not this particular error message.
A3: C. enqueue queues incoming runs behind the active one, processing them in order. It is Platform-only — it requires Postgres to durably queue runs across server restarts. In langgraph dev (in-memory), enqueue is not reliably available; only reject is guaranteed. interrupt (A) stops the current run; rollback (B) undoes it — neither preserves all 10 runs.
A4: B. @auth.authenticate is global — it validates every request and returns the user identity. It does not know which specific thread a run is targeting. @auth.on.runs.create fires before a run is created, receives the AuthContext (including ctx.user.identity), and lets you check thread ownership and raise a 403 if the caller is not the owner. Answer A is wrong because @auth.authenticate cannot access resource context. Answer C only controls thread creation, not run creation. Answer D is wrong — the server has no automatic ownership scoping.
A5: B. RemoteGraph("forge", url="http://localhost:2024") exposes the same .invoke(), .ainvoke(), .stream(), .astream() interface as a compiled graph. Replace build_graph() with RemoteGraph(...) and the rest of your streaming code is unchanged. The low-level SDK (A) requires rewriting the stream loop. There is no auto-routing via env var (C). get_sync_client (D) is the synchronous client variant — it does not expose the graph .astream() interface.
Traps to avoid
Trap 1 — The rename trap (freshness, October 2025). Seeing “LangGraph Platform” in an old tutorial and “LangSmith Deployment” in the current UI and assuming they are two different products. They are the same product. When referencing either, always name both at first mention. This confusion trips up people searching the docs and the UI simultaneously.
Trap 2 — Hard-coding SqliteSaver in build_graph(). If build_graph() does not accept checkpointer as an optional parameter and always compiles with a SqliteSaver, the Agent Server cannot inject its own checkpointer — the server will fail to start or will use the wrong persistence backend. Always write build_graph(checkpointer=None, store=None) and let callers (local scripts or the server) provide what they need.
Trap 3 — Assuming enqueue/interrupt/rollback work in langgraph dev. In langgraph dev (in-memory), only reject is reliably supported. The queue-based strategies need Postgres to persist the run queue durably across the server’s async event loop. Testing double-texting locally with enqueue may seem to work for simple cases but will silently misbehave under real load. Test these strategies against langgraph up (with Postgres) or LangGraph Platform / LangSmith Deployment before relying on them.
Key Takeaways
- LangGraph Platform = LangSmith Deployment (renamed October 2025) — the managed cloud layer. The self-hosted Agent Server (
langgraph-api) is Layer 2: same API, you manage the infra. The OSS runtime is Layer 1: everything you have built since Module 1. langgraph.jsonis the deployment manifest:dependencies(your package),graphs(graph IDs to factory symbols),env(secrets file),python_version,auth(custom auth object), and optionallystoreandhttp. Thegraphssymbol must be a compiled graph variable or a zero-argument factory.build_graph(checkpointer=None, store=None)is the correct signature for a deployable factory. The Agent Server injects its own checkpointer; do not hard-codeSqliteSaverinside the factory.langgraph devstarts the Agent Server in-memory with hot-reload and LangGraph Studio in one command. Studio gives you graph view, thread state inspection, time-travel, and an interrupt resolution UI — the visual interface for everything you built in M7 and M8.- Assistants, threads, runs are the three resource types in the Agent Server API. Use
get_client(the async SDK) for explicit control; useRemoteGraphwhen you need the same compiled-graph interface as local code. - Double-texting strategies —
reject(default),enqueue,interrupt,rollback— control what happens when a second run arrives on an active thread.enqueueis the CI-safe choice; the three non-reject strategies are Platform-only (require Postgres-backed Agent Server). - Custom auth uses
Auth(fromlanggraph_sdk) +@auth.authenticate(global token validation returningMinimalUserDict) +@auth.on.*handlers (per-resource authorization rules). Wire it inlanggraph.jsonwith"auth": {"path": "./forge/auth.py:auth"}.
What’s Next
Forge is now running as an HTTP service — deployed, authenticated, and visible in Studio. The next question is: does it actually solve issues correctly? Module 15 adds LangSmith tracing to every Forge run, builds a golden set of 10 real Tally issues, wires an LLM-as-judge evaluator, sets up a CI regression gate, and ships Forge v1.0.
Want the full LangGraph mastery question bank (interview-style) and the next module in your inbox? Subscribe here — it’s free, like everything in this series.
Links:
- Lab code — module-14
- Previous: Module 13 — The Functional API
- Next: Module 15 — Evaluate with LangSmith and Ship Forge v1.0
- Course index
- Module 8 — Time-travel and Debugging (Studio reinforces this)
- Module 12 — Composition and Multi-Agent (
RemoteGraphas node)
References
- LangGraph Platform / Deployment concepts — langchain-ai.github.io/langgraph/concepts/langgraph_platform (as of June 2026)
langgraph.jsonconfiguration reference — langchain-ai.github.io/langgraph/reference/configuration (as of June 2026)- Python SDK reference (
get_client, assistants, threads, runs) — langchain-ai.github.io/langgraph/reference/sdk/python_sdk_ref (as of June 2026) RemoteGraphAPI reference — langchain-ai.github.io/langgraph/reference/pregel/#langgraph.pregel.remote.RemoteGraph (as of June 2026)- Custom auth guide — langchain-ai.github.io/langgraph/tutorials/auth/getting_started (as of June 2026)
- LangGraph Studio — smith.langchain.com/studio (as of June 2026; requires a running server or a LangGraph Platform / LangSmith Deployment)