Deploy Forge: LangGraph Platform, the Server API, and Studio (LangGraph in Production, Module 14)

Module 14 of 16 30 min read Lab code ↗

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.json manifest that describes Forge for deployment, field by field.
  • How to launch Forge with langgraph dev and navigate LangGraph Studio to inspect threads, time-travel, and resolve a stuck interrupt().
  • How to consume Forge from a Python SDK client: get_client, assistants, threads, and streaming runs, plus RemoteGraph as 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"]
GoalTool
Local dev and debugging with Studiolanggraph dev (Layer 2, in-memory)
Self-hosted production, full controllanggraph up (Docker + Postgres + Redis)
Managed cloud, no ops overheadLangGraph 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.com UI (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-api is 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 imports graph directly).
  • 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). In langgraph dev it is in-memory; in langgraph up or 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:

  1. The CLI parses langgraph.json, imports build_graph from forge/graph.py, and calls it to compile the graph.
  2. It imports auth from forge/auth.py and registers it as the authentication layer.
  3. It starts an HTTP server on localhost:2024 (the default port) — no Docker, no external services.
  4. It prints a Studio URL: https://smith.langchain.com/studio?baseUrl=http://localhost:2024.
  5. 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:

CommandWhat it doesWhen to use
langgraph devIn-memory server, hot-reload, StudioLocal development and debugging
langgraph build -t my-imageBuilds a Docker image of the Agent ServerPreparing for self-hosted production
langgraph upRuns the Docker image with Postgres + RedisSelf-hosted production
langgraph dockerfileGenerates a customizable DockerfileWhen 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(...) with await does).

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 (using RemoteGraph).

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(...):

StrategyBehaviorBest for Forge
reject (default)The second run is rejected immediately with 409Strict HITL: never process two issues in parallel on the same thread
enqueueThe second run waits in a queue until the first completesCI burst: issues arrive in batches; process sequentially, lose none
interruptThe current run is interrupted cleanly; the new run startsInteractive session: the latest instruction wins
rollbackThe current run and its checkpoints are rolled back; the new run starts from the last clean checkpointCorrecting 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.authenticate runs on every request — before routing, before resource checks. It receives the raw Authorization header (via the authorization parameter name, which the SDK maps automatically), validates the token, and returns a MinimalUserDict with at minimum an "identity" key. If validation fails, raise Auth.exceptions.HTTPException with the appropriate status code.

  • @auth.on.threads.create is 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 in langgraph.json or 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:

  1. Graph view — Forge’s full StateGraph. The intake → triage → recall → planner → plan_approval chain is visible. Click on the editor node — you should see the fan-out edges to apply.
  2. 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

  1. Extend the auth policy. Add @auth.on.threads.read to auth.py so that a user can only read threads they own (where metadata["owner"] == ctx.user.identity). Verify that a second token cannot read threads created by the first.

  2. Add a second assistant with a different config. The client.assistants.create call accepts a config dict — pass {"configurable": {"model_tier": "fast"}} and wire context_schema in the graph to pick it up. Name this assistant "forge-fast" and run it side-by-side with the default one.

  3. 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 in langgraph 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.json is the deployment manifest: dependencies (your package), graphs (graph IDs to factory symbols), env (secrets file), python_version, auth (custom auth object), and optionally store and http. The graphs symbol 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-code SqliteSaver inside the factory.
  • langgraph dev starts 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; use RemoteGraph when you need the same compiled-graph interface as local code.
  • Double-texting strategiesreject (default), enqueue, interrupt, rollback — control what happens when a second run arrives on an active thread. enqueue is the CI-safe choice; the three non-reject strategies are Platform-only (require Postgres-backed Agent Server).
  • Custom auth uses Auth (from langgraph_sdk) + @auth.authenticate (global token validation returning MinimalUserDict) + @auth.on.* handlers (per-resource authorization rules). Wire it in langgraph.json with "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:


References

  1. LangGraph Platform / Deployment concepts — langchain-ai.github.io/langgraph/concepts/langgraph_platform (as of June 2026)
  2. langgraph.json configuration reference — langchain-ai.github.io/langgraph/reference/configuration (as of June 2026)
  3. Python SDK reference (get_client, assistants, threads, runs) — langchain-ai.github.io/langgraph/reference/sdk/python_sdk_ref (as of June 2026)
  4. RemoteGraph API reference — langchain-ai.github.io/langgraph/reference/pregel/#langgraph.pregel.remote.RemoteGraph (as of June 2026)
  5. Custom auth guide — langchain-ai.github.io/langgraph/tutorials/auth/getting_started (as of June 2026)
  6. LangGraph Studio — smith.langchain.com/studio (as of June 2026; requires a running server or a LangGraph Platform / LangSmith Deployment)