hi @justOleh and @matthew
yes, this is an interesting thing.
Why a schema and not an instance?
StateGraph takes a type because at build time LangGraph only needs the shape of the state - which keys exist and which reducer each one uses. The graph is compiled once per application, but state values live per thread. One compiled graph serves thousands of conversations, each with its own checkpointed state, so initial values cannot belong to the builder - they belong to the thread.
You actually can start a graph in a certain state.
The input to the first invoke on a thread is the initial state. You can pass a SystemMessage as the first message:
graph.invoke(
{"messages": [SystemMessage(content="You are a helpful assistant."), HumanMessage(content="Hi!")]},
config,
)
With the course’s operator.add reducer (or the recommended add_messages one) it gets checkpointed once and stays at position 0 for all later turns - just note add_messages is preferred nowadays since it deduplicates by message ID. And if you want to seed state before any run at all, use graph.update_state(config, values) - it writes a checkpoint as if a node had produced those values, then you invoke normally.
So why does the course add the system message on every LLM call?
It is a config vs data separation. If the prompt is baked into checkpointed state, it is frozen at thread creation - ship a v2 of your prompt and every existing conversation still runs the old one. Prepending it transiently at call time (note the course code never returns the system message into state, so the reducer never duplicates it) keeps checkpoints clean: they store only what the user and model actually said, so you can replay a thread under a different prompt or model. It also allows different prompts per node over the same shared messages.
The patterns:
- Runtime context, langgraph >= 0.6 - exactly the feature you are asking for: run-scoped values that are not checkpointed:
@dataclass
class Context:
system_prompt: str
def call_model(state: AgentState, runtime: Runtime[Context]):
messages = [SystemMessage(content=runtime.context.system_prompt)] + state["messages"]
return {"messages": [model.invoke(messages)]}
builder = StateGraph(AgentState, context_schema=Context)
graph.invoke({"messages": [...]}, context=Context(system_prompt="You are..."))
- With create_agent you do not hand-roll this at all: the system_prompt parameter for a static prompt, or the dynamic_prompt middleware for one that reads state, runtime context or the store.
initial state per thread goes through the first invoke input or update_state; the system prompt should stay out of state - use context_schema plus Runtime, or system_prompt / dynamic_prompt on create_agent.
Docs: Context overview - Docs by LangChain and Context engineering in agents - Docs by LangChain