Skip to main content

Build agents with LangGraph

LangGraph is an open-source Python framework that models an agent as a graph: nodes do work (call a model, run a tool), edges decide what happens next. The Agents service runs unmodified LangGraph agents. This page is the complete guide: the author contract, both ways to start (from source or from a Console template), configuration, the deploy, invoking and streaming, sessions, memory, tools from MCP Servers, SDK snippets, common patterns, the debugging checklist, and limits.

Before you begin

  • You can deploy: account, CLI, and sign-in per deploy an agent.
  • Python knowledge. You do not need LangGraph installed locally to deploy — the build happens on the platform.
  • Your project has a model API key saved (console Project Settings, or PUT /v1/projects/{id}/inference). Deploying an agent that calls a model is refused without one.

What your graph.py must define

Your directory must contain a graph.py that defines a variable named graph at module level — at the top level of the file, not inside a function. It has three requirements:

  1. It must be a compiled graph: a StateGraph you have called .compile() on.
  2. Its state must be LangGraph's MessagesState.
  3. That means its input is a dict shaped like {"messages": [...]}.

Why that shape? The harness keeps every conversation as an ordered list of turns and replays it into your graph as messages at the start of each new turn. That replay is what makes turn 2 remember turn 1. A graph built on MessagesState accepts it without any conversion.

The easiest way to meet all three requirements is LangGraph's own create_react_agent, which returns exactly that kind of compiled graph. If the module-level graph is missing, your agent crash-loops — it starts, fails, restarts, and fails again — and the harness startup error appears in the agent's message field.

An optional requirements.txt is installed at build time, checked against the versions the base image already pins. If you pin a version that conflicts with one of those, the build fails and the reason is readable in the agent's message field. The alternative would be an instance that crash-loops on import in production, so this trade is deliberate.

What the base image already provides

The LangGraph base image ships langgraph, langchain-core, langchain-openai, and the first-party crusoe_langchain package, on Python 3.12. Most agents need no extra dependencies.

Build from source, or start from a template

Build from source. Write graph.py (and a tools.py beside it if your graph has custom tools) in a folder on your machine. The complete example below is deployable as-is; so are the langgraph-minimal and langgraph-mcp examples in the platform's examples folder.

Start from a Console template. In the Console, go to Compute → Agents → Deploy agent, pick LangGraph, and choose the write mode. The dialog opens with a working LangGraph starter — a compiled create_react_agent graph you edit in the browser and deploy with no local setup.

Either way you end with the same thing: a directory whose graph.py defines a compiled graph, deployed by the same platformctl deploy (or its API/Console equivalent).

Configure the framework: env, secrets, MCP attach

Everything below is set per agent and takes effect on the next revision. Full reference: secrets and environment variables.

Environment variables (readable)

platformctl agents env set my-graph-agent CHAT_MODEL=nvidia/NVIDIA-Nemotron-3-Super-120B-A12B
VariableEffect
CHAT_MODELThe model crusoe.foundry_model() picks when called with no argument
CAI_EXPOSE_EXTERNAL=apikey|jwt|nonePublishes the agent's public URL, and names the protection it gets. A bare true is refused — see invoke
CAI_EXPOSE_RATE_LIMIT=100/minuteRequired whenever exposure is on: N/second|minute|hour|day
MEMORY_SCOPE=sharedOne common memory bank for all callers — see memory

TOOL_SANDBOX=false — the opt-out from writing your own tools in sandboxes — is not settable here. PATCH /v1/agents/{name}/env refuses it with a 400 naming the rule, because it is in the platform's reserved set. Store it as a per-agent secret instead: platformctl secrets set my-graph-agent TOOL_SANDBOX=false.

Secrets (write-only)

platformctl secrets set my-graph-agent MODEL_API_KEY=«redacted:sk-…»

MODEL_API_KEY overrides the project's model key for this one agent. Anything else — a downstream API token — follows the same pattern and is read in Python with os.environ["YOUR_KEY"]. That read works in the agent process; it does not work inside a sandboxed tool, whose environment is empty.

For a credential shared across agents, with versions and read auditing, keep it in the project Secrets store and read it at call time:

import crusoe_langchain as crusoe

api_key = crusoe.secret("weather-api-key") # latest version
pinned = crusoe.secret("weather-api-key", 3) # a pinned version

crusoe.secret() reads from your agent process. It does not work inside a sandboxed tool: the sandbox has no environment and no route to the secrets API, so the call raises SecretError: crusoe.secret is not configured. A tool that must hold a credential or reach an internal service belongs in an MCP server, or the agent must run with TOOL_SANDBOX=false. See use secrets in workloads.

MCP servers attach automatically

There is nothing to attach — no flag, no field, no console control. At deploy time the platform writes MCP_SERVERS into the agent's environment with an entry for every MCP server in the project whose state is ready, each carrying that server's name, its private address — reachable from your workloads, never from the internet — and its own bearer token. One helper turns them into LangChain tools. crusoe.mcp_tools() is async, and the tools must exist before the compiled graph does, so resolve them once at import with asyncio.run — at import time there is no running event loop yet, so this is safe at exactly that one place:

import asyncio

from langgraph.prebuilt import create_react_agent

import crusoe_langchain as crusoe

_mcp_tools = asyncio.run(crusoe.mcp_tools()) # resolve attached servers' tools once, at startup

graph = create_react_agent(
crusoe.foundry_model(),
tools=[crusoe.run_python, *_mcp_tools],
prompt="Prefer an attached tool when one fits; use run_python for computation.",
)

With no servers attached, mcp_tools() returns an empty list and the graph still compiles. With an attached server that is unreachable, the error surfaces at import and the agent crash-loops loudly rather than serving a graph that silently lost tools — LangGraph's helper awaits get_tools(), so it is genuinely fail-closed at startup. Every newly published tool on the server reaches the agent on its next deploy — no code change.

Order matters: MCP_SERVERS is captured at deploy

MCP_SERVERS is baked into the agent's revision at deploy time. A server created — or first reaching readyafter an agent was deployed does not reach that agent until you redeploy it. Build the server first, wait for ready, then deploy the agent:

platformctl mcp get doc-search-mcp # wait until state is ready
platformctl deploy ./my-graph-agent --name my-graph-agent

An agent deployed too early starts cleanly with no tools and says nothing about it. You cannot read MCP_SERVERS back to check — platformctl agents env get my-graph-agent returns only the variables you set yourself — so the check is to invoke the agent and look for the server's tools in tool_calls.

Attach only the servers you name

crusoe.mcp_tools() takes a names= argument — a single name, or a list of them:

_mcp_tools = asyncio.run(crusoe.mcp_tools()) # every attached server
_mcp_tools = asyncio.run(crusoe.mcp_tools(names="doc-search-mcp")) # just that one
_mcp_tools = asyncio.run(crusoe.mcp_tools(names=["doc-search-mcp", "weather-tools"]))

The default (no names=) is right for most agents: publishing a new tool to an attached server then needs no agent change. Name them when an agent should not see every tool in the project, or must not have its toolset widened the day somebody else deploys a server. A bare string counts as one name, not an iterable of characters; an explicit empty list attaches nothing.

A name that is not attached raises crusoe_core.UnknownMCPServer rather than quietly attaching nothing, and the message names what is attached:

MCP server(s) not attached to this project: doc-serch-mcp. Attached: doc-search-mcp, weather-tools

crusoe_core.mcp_server_names() returns that same attachable list. Attaching several servers at once is ordinary — one turn can call a tool from each — but tool names are not namespaced by server, so two servers publishing the same tool name collide.

The langgraph-mcp example in the examples folder is this section, runnable. See publish tools and connect agents and clients for the end-to-end walk.

The Crusoe helpers: crusoe_langchain

import crusoe_langchain as crusoe
  • crusoe.foundry_model() — returns a LangChain ChatOpenAI already pointed at the platform's managed inference endpoint. It reads MODEL_BASE_URL, CHAT_MODEL, and MODEL_API_KEY for you. With no arguments it uses whatever CHAT_MODEL is set to; when nothing is set anywhere, the platform default is zai/GLM-5.2. To pin a model in code, pass its name: crusoe.foundry_model("nvidia/NVIDIA-Nemotron-3-Super-120B-A12B"). You can also bring your own LangChain model instead — with your own credentials — and the harness runs it as-is.
  • crusoe.run_python — a tool that runs Python in the platform's code sandbox: isolated, used once, capped at 20 seconds.
  • crusoe.search_memory — a tool that searches the agent's long-term memory bank and returns the top 5 snippets.
  • crusoe.secret(name) — reads a project Secret at call time, described above.

The platform's own tools run inside the agent process. Tools you write yourself are moved into a sandbox by default, under the TOOL_SANDBOX setting; opt out with platformctl secrets set my-graph-agent TOOL_SANDBOX=false — a per-agent secret, because the env route refuses that name. See tools.

Writing your own tools

This is the section to read before you write your first tool, because the sandbox has a shape requirement and it is enforced at startup, not at call time.

Why there is a shape requirement

TOOL_SANDBOX defaults to true. Under that default, the harness moves the body of every tool you wrote out of the agent itself and runs it in a sandbox used once and discarded. It does that by sending the sandbox three things: the module name, the function name, and the call's arguments as JSON. The sandbox imports that module and looks up that function by name.

So a tool body has to be a plain, named function defined at module level — something the sandbox can find by name. A function with no name, or one that exists only inside another function, cannot be found that way.

Faced with such a tool, the harness refuses to start rather than quietly run it inside the agent. The agent holds MODEL_API_KEY and every secret you injected. A security boundary that silently disappears for one tool is worse than no boundary at all, because you would not know.

Platform tools are unaffected. crusoe.run_python and crusoe.search_memory isolate themselves already, and remote MCP tools already run on another machine. The harness recognizes both and leaves them alone.

Write it like this

Put your tools in their own module next to graph.py — the sandbox re-imports that module by name, and a module that only defines functions is the cheapest thing to re-import.

my-graph-agent/tools.py:

"""Tools for the graph. Module-level, named, synchronous functions."""
from langchain_core.tools import tool


@tool
def word_count(text: str) -> int:
"""Count the words in a piece of text."""
return len(text.split())

my-graph-agent/graph.py:

from langgraph.prebuilt import create_react_agent

import crusoe_langchain as crusoe
from tools import word_count

graph = create_react_agent(
crusoe.foundry_model(),
tools=[crusoe.run_python, word_count],
prompt="You are a helpful assistant.",
)

Three rules, and that is the whole list:

  1. Module level. Define the function at the top level of a file, not inside another function, and never as a lambda.
  2. Synchronous. Use def, not async def. An async-only tool has no synchronous body to relocate.
  3. A function, not a class. Use the @tool decorator on a function. A BaseTool subclass that keeps its body in a _run method has no separate callable to move.

Arguments and return values cross into the sandbox as JSON, so keep them to strings, numbers, booleans, lists, and dicts.

Three startup failures, and what each one means

If your tool breaks one of those rules, the agent never serves a request. It crash-loops on startup instead, and the error shows up in both the agent's message field and its logs. Read it with platformctl logs my-graph-agent. All three errors arrive wrapped in this outer line:

tool sandboxing is enabled but could not be installed: <the specific error below>. Set TOOL_SANDBOX=false to deliberately run tool code in the agent process.

A class-based tool with its body in _run:

tool 'word_count' has no (module, function) body to sandbox; set TOOL_SANDBOX=false to run it in the agent process deliberately.

Rewrite it as an @tool-decorated function.

An async-only tool (you wrote async def, so only a coroutine exists):

async-only user tool 'word_count' cannot be sandboxed; set TOOL_SANDBOX=false to run it in the agent process deliberately.

Give it a synchronous body. A tool that mostly waits on the network does not gain much from async here anyway — it already runs in its own sandbox.

A lambda, or a function defined inside another function. The second case is a closure, a function that carries values from the scope it was defined in. The harness spots both by name: a lambda reports its name as <lambda>, and a nested function's full name contains <locals>.

tool callable <function build_tools.<locals>.word_count at 0x7f3c...> is not (module, function)-addressable and cannot be sandboxed; set TOOL_SANDBOX=false to run it in the agent process deliberately.

Move the function out to module level. If it was nested so it could capture a value from the surrounding scope, pass that value as a tool argument — do not reach for an environment variable inside the function. A sandboxed tool sees neither the agent's environment nor anything its module captured at import in the agent process, so os.environ["MY_VAR"] raises KeyError there and arrives at the model as the tool result ERROR: 'MY_VAR' — quieter than the startup crash it replaced. If the value is a credential, the tool belongs in an MCP server instead. See the sandbox boundary.

One more failure exists that you can only hit at call time, never at startup:

arguments to word_count are not JSON-serialisable, so the call cannot cross the sandbox boundary: ...

That means the model passed an argument shape your tool signature accepts but JSON cannot carry. Keep tool parameters to plain types.

Confirm the sandbox is actually on

At startup the harness prints exactly one of these lines. Read it with platformctl logs my-graph-agent — every startup line is prefixed [startup], so that is a good thing to search for:

[startup] tool sandbox ON: word_count run in isolated sandboxes
[startup] tool sandbox ON: no module-addressable user tools to isolate (platform tools self-sandbox; remote MCP tools run remotely)
[startup] tool sandbox OFF (TOOL_SANDBOX=false): tool code runs in the agent process

The middle line is the one to look at twice — see the warning below.

A hand-built graph gets no tool sandboxing at all

The harness finds your tools by looking for the tool map that LangGraph's create_react_agent builds — its ToolNode. That is the shape this page's example uses, and the shape almost every LangGraph tutorial uses.

Now suppose you build your graph by hand: your own StateGraph, with your own node function calling tools directly instead of a ToolNode. The harness then cannot find your tools at all. It does not fail, and it does not warn you specifically. It finds nothing to isolate, logs the "no module-addressable user tools to isolate" line above, and starts normally. Your tool code then runs inside the agent itself, next to MODEL_API_KEY and every secret you injected.

No flag fixes this today. Isolating the tools of an arbitrary hand-built graph is not implemented. So choose deliberately. If you need the sandbox boundary, build your graph with create_react_agent. If you need a hand-built graph, treat your tool code as trusted code holding your agent's full credentials, and keep anything the model chose out of dangerous operations — send that to crusoe.run_python instead.

Either way, if you see the "no module-addressable user tools to isolate" line and you know you shipped tools, that is the signal: they are not being sandboxed.

Complete example: langgraph-minimal

The platform's canonical LangGraph example, complete and runnable as-is. Two files.

my-graph-agent/graph.py:

"""A minimal LangGraph agent for the Crusoe platform.

The author contract (mirrors ADK's module-level ``root_agent``): expose a
module-level ``graph`` that is a COMPILED StateGraph over ``MessagesState``.
``create_react_agent`` returns exactly that - a compiled graph whose input is
``{"messages": [...]}`` - so the harness can replay the canonical session log
into it each turn and stream its output.
"""
from langgraph.prebuilt import create_react_agent

import crusoe_langchain as crusoe

graph = create_react_agent(
# foundry_model() with no args uses the platform default (zai/GLM-5.2
# unless CHAT_MODEL is injected/overridden). Override by name with
# crusoe.foundry_model("nvidia/NVIDIA-Nemotron-3-Super-120B-A12B"), or
# bring your own native model, with your own credentials - the harness
# runs it as-is.
crusoe.foundry_model(),
tools=[crusoe.run_python, crusoe.search_memory],
prompt=(
"You are Research Buddy, a research assistant. Use the run_python tool "
"for calculations and the search_memory tool to recall things you were "
"told to remember."
),
)

my-graph-agent/requirements.txt:

# The harness-langgraph base image already provides langgraph, langchain-core,
# langchain-openai and crusoe_langchain, so this agent needs no extra
# dependencies. Add third-party packages your graph imports here; they are
# installed against the base image's constraints (a conflicting pin fails the
# build rather than crash-looping the agent).

Deploy it

The CLI sees graph.py and auto-detects the framework as langgraph.

platformctl deploy ./my-graph-agent --name my-graph-agent

You should see:

packaging ./my-graph-agent...
uploading my-graph-agent (1.4 KiB, framework=langgraph)...
build 7a3b9c1d-2e4f-4a6b-8c0d-1e2f3a4b5c6d accepted
state: -> building
state: building -> deploying
state: deploying -> ready
my-graph-agent is ready at https://my-graph-agent-x7k2q.apps.codyhill.dev

Invoke it: the sync response

Once deployed, a LangGraph agent answers the exact same HTTP API as every other agent — callers can't tell the frameworks apart. This message exercises the model and the sandbox tool in one turn.

platformctl invoke my-graph-agent "Compute 2**32 in python."

You should see:

2**32 is 4294967296.
(session: 9d8c7b6a-5e4f-4d3c-2b1a-0f9e8d7c6b5a)
tool_call: run_python called with args={'code': 'print(2**32)'}

Full request/response field reference, status codes, and the public-URL variant: invoke an agent.

Invoke it streaming: NDJSON

POST /v1/agents/{name}/invoke/stream takes the same request body and answers with application/x-ndjson — one complete JSON object per line, in the order things happened:

curl -sN -X POST "$CAI_API/v1/agents/my-graph-agent/invoke/stream" \
-H 'content-type: application/json' \
-d '{"message":"Compute 2**32 in python."}'

You should see (one object per line):

{"type":"thinking", "seq":1, "text":"I should run this computation..."}
{"type":"block_end", "seq":2, "kind":"thinking"}
{"type":"tool_call", "name":"run_python", "args":{"code":"print(2**32)"}}
{"type":"tool_result", "name":"run_python", "result":"4294967296\n"}
{"type":"output", "seq":3, "text":"2**32 is "}
{"type":"output", "seq":4, "text":"4294967296."}
{"type":"block_end", "seq":5, "kind":"output"}
{"type":"done", "session_id":"9d8c7b6a-5e4f-4d3c-2b1a-0f9e8d7c6b5a", "user_id":"7c9e6679-7425-40de-944b-e07fc1f90ae7"}

Line-by-line semantics, including the error line that arrives instead of done on a failed turn: invoke → streaming.

Sessions: the replay, and turn 2 remembering turn 1

Sessions are the reason the graph contract above is shaped the way it is. On each new turn of a conversation, the harness reads the stored canonical event log for the session and replays it into your compiled graph as {"messages": [...]} — your graph's own input type. Turn 2 sees turn 1 because the platform feeds it back in, not because the model remembers anything.

platformctl invoke my-graph-agent "My boat is a Mastercraft Maristar 245."
# -> (session: 9d8c7b6a-5e4f-4d3c-2b1a-0f9e8d7c6b5a)

platformctl invoke my-graph-agent "What boat do I have?" \
--session 9d8c7b6a-5e4f-4d3c-2b1a-0f9e8d7c6b5a
# -> You have a Mastercraft Maristar 245.

Your graph code touches none of this — no checkpointer, no store, no thread id. The MessagesState contract plus the harness's replay is the whole mechanism. Browse the stored transcript with platformctl agents sessions get my-graph-agent <session-id> -o json, or in the Console's Users & Sessions tab. Full mechanics and id rules: sessions.

Memory: memorize a session, recall it in another

Sessions are per-conversation. For facts that must survive across sessions, memorize explicitly, then let the search_memory tool find them from any later conversation:

# 1) Have a conversation, note the session id
platformctl invoke my-graph-agent "My boat is a Mastercraft Maristar 245."

# 2) Commit it to the memory bank (needs sign-in)
platformctl memorize my-graph-agent --session 9d8c7b6a-5e4f-4d3c-2b1a-0f9e8d7c6b5a

# 3) A brand-new session — no --session flag
platformctl invoke my-graph-agent "What do you know about my boat?"

You should see:

You have a Mastercraft Maristar 245.
(session: 47c0e1a2-9f3b-4d5e-8a7c-2b6d0e4f1a3c)
tool_call: search_memory called with args={'query': 'boat'}

Scope, privacy, MEMORY_SCOPE, and the read/write routes: long-term memory.

SDK snippets

There is no generated Agents SDK to install; the invoke endpoint is plain HTTP, so any HTTP client works.

import httpx

API = "https://api.codyhill.dev"
AGENT = "my-graph-agent"

def chat(message: str, session_id: str | None = None) -> dict:
body = {"message": message}
if session_id:
body["session_id"] = session_id
resp = httpx.post(f"{API}/v1/agents/{AGENT}/invoke", json=body, timeout=120)
resp.raise_for_status()
return resp.json()

turn1 = chat("My boat is a Mastercraft Maristar 245.")
turn2 = chat("What boat do I have?", session_id=turn1["session_id"])
print(turn2["output"])

Common patterns

Tool-calling with verification. Give the graph crusoe.run_python (and your own @tool functions from tools.py) and prompt it to compute rather than guess. Verify a call fired three ways: tool_calls in the sync response, the tool_call/tool_result stream lines, or the function_call parts in the session transcript.

Multi-turn chat. Reuse session_id, always. The harness handles the history replay; your own client just stores the id from turn 1 and sends it back on turn 2.

RAG over long-term memory. Teach facts with platformctl memorize, then give the graph crusoe.search_memory and a prompt to consult it. Per-caller privacy is the default; MEMORY_SCOPE=shared pools the bank deliberately. For retrieval over your own documents instead of memorized chat, use Vectors directly — see integration examples.

Agent + MCP Servers. asyncio.run(crusoe.mcp_tools()) at import attaches every tool your project's ready MCP Servers publish, or crusoe.mcp_tools(names="doc-search-mcp") for one of them; see MCP servers attach automatically.

Debugging checklist

Work top to bottom; each step names the exact string it should confirm or produce.

  1. Did the deploy reach ready? platformctl status my-graph-agent. On failed, the message field holds the tail of the real build output.
  2. Is graph at module level, compiled, over MessagesState? A missing or uncompiled one crash-loops with the harness startup error in the message field.
  3. Is a model key in force? Publish the agent, then GET <public_url>/debug/config for model_key_present and the resolved model — never the key itself.
  4. Did your tool actually get sandboxed? platformctl logs my-graph-agent --history | grep "tool sandbox". Unlike ADK, LangGraph refuses to start on a non-sandboxable tool — but a hand-built graph without a ToolNode slips through silently. The startup line is the confirmation either way.
  5. Is the turn failing, or the tool inside it? A tool raising in the sandbox does not fail the invoke — the model gets a tool result starting with ERROR: and answers from it. Look for that prefix in the stream or transcript.
  6. Does turn 2 forget turn 1? You changed session_id between calls. Reuse it. If your graph has a custom state schema that drops messages, that is the other way to break continuity — stick to MessagesState.
  7. Did invoke hang ~60s then 502? Cold start outrunning the invoke timeout — retry; slow imports are the usual cause.
  8. Is search_memory returning nothing? Nothing memorized for that caller yet, or legacy unattributed memories — see the memory page for the MEMORY_SCOPE=shared remedy.

Everything here, with the verbatim error strings, is in troubleshooting.

Limits and costs

LimitValue
Deploy upload100 MiB tarball; one build at a time per agent
Invoke1 MiB request, 32 MiB buffered response, 60 s default timeout
crusoe.run_python20 s per execution, DNS-only network
Your own tools (sandboxed)30 s default / 120 s max per call; public internet reachable
MCP attachResolved once at import; an unreachable server fails startup loudly
Conversation stateSessions kept until deleted (admin-settable expiry); the memory bank has no expiry

Cost works like every serverless workload here: an idle agent scales to zero and bills nothing; per-turn cost is model tokens (on your own inference key), plus any sandboxed tool executions. The project quota on total services can refuse a deploy (409) — that's a quota, not a fee. Full list: limits reference.

Clean up

platformctl delete my-graph-agent

Deleting the agent removes its runtime, not its data: sessions and the memory bank survive. The deletion walk is in deploy → clean up.

Next steps

  • Integration examples — LangGraph agent plus Vectors, Memory Store, Secrets, and MCP Servers, end to end.
  • Invoke — sessions, streaming, and the full request/response shapes.
  • Sessions — how the message replay works.
  • Tools — write your own tool functions.
  • Same agent, other frameworks: ADK, CrewAI.