skip to content

How do you use LangGraph's update_state to edit graph state before resuming a run?

level: seniorimportance: should knowfreq 45%

answer

  1. Reducers apply, not blind overwrite
  2. Same message id replaces in place
  3. as_node decides which edges run next
  4. Writes a checkpoint, keeps the old one
  5. Correction, not the resume answer

basics

~20 s

graph.update_state(config, values, as_node=...) writes a new checkpoint on the paused thread. The values pass through the state schema's reducers, and as_node decides whose outgoing edges run next. You then continue the thread with invoke(None, config) or a Command resume.

solid answer

~50 s

`update_state(config, values, as_node=None)` is how a human edit gets into a paused run. It takes the thread's config, applies `values` **through the state schema's reducers** — so an `add_messages` channel appends rather than replaces, while a plain channel overwrites — and commits a new checkpoint, returning a config pointing at it. The `as_node` argument is the subtle part: it tells LangGraph to record the update as if that node had produced it, which determines the edges followed when the run continues; omitted, it defaults to the node that last wrote, and is ambiguous if several did. The classic use is fixing a bad tool call at an `interrupt_before=["tools"]` gate: pass a replacement `AIMessage` carrying the **same id** as the original, because `add_messages` replaces by id, then resume with `graph.invoke(None, config)`. `RemoveMessage(id=...)` deletes one instead. Because the write is a normal checkpoint, the edit is auditable and the pre-edit checkpoint remains on the thread's history.

code

python · 15 lines
python
config = {"configurable": {"thread_id": "t-3"}}
snapshot = graph.get_state(config)

bad = snapshot.values["messages"][-1]          # AIMessage with a wrong tool call
fixed = bad.model_copy(update={
    "tool_calls": [{
        "name": "transfer",
        "args": {"amount": 50},
        "id": bad.tool_calls[0]["id"],
    }]
})

# same id => add_messages replaces the message instead of appending
graph.update_state(config, {"messages": [fixed]}, as_node="agent")
graph.invoke(None, config)

go deeper

for a junior

Know that a paused run's state can be edited from outside with update_state, and that you then continue the same thread rather than starting a new one. Recall that the edit is stored as another checkpoint.

for a middle

Be ready to explain that values pass through the state schema's reducers, so an add_messages channel appends unless the incoming message reuses an existing id, and that as_node controls which outgoing edges run when the graph continues.

for a senior

Show the full correction flow: read the snapshot, rewrite the proposed tool call in place, resume with None, and answer any rejected call with a matching ToolMessage. Point out that the pre-edit checkpoint remains as an audit record of model-proposed versus human-sent.

for a principal

Own the governance angle: state edits are privileged writes, so decide who may make them, where the actor identity is recorded given that checkpoints store values rather than authors, and what review exists over an escape hatch that can reroute a run to any node.

## What it is for Approval is rarely binary. Often the reviewer's answer is "almost — but change this argument", or "drop that message and try again". `update_state` is the API that turns a human correction into graph state. ## The call `new_config = graph.update_state(config, values, as_node="agent")` - `config` identifies the thread (and optionally a specific checkpoint) you are editing. - `values` is a partial state update, shaped like a node's return value. - `as_node` attributes the update to a node. It returns a config whose checkpoint id points at the newly written checkpoint. ## Reducers apply — this is the number-one surprise `update_state` does not blindly overwrite. It routes `values` through exactly the same channel reducers the graph uses for node returns. On a plain channel (no reducer) the new value replaces the old. On a channel annotated with `add_messages`, the update **appends**. So handing `{"messages": [AIMessage("corrected")]} ` to a message channel does not replace the conversation — it adds a turn. The way to actually replace a message is to exploit `add_messages`'s id semantics: a message whose `id` equals an existing message's id overwrites that message in place. So you read the offending message from `graph.get_state(config).values`, construct a corrected `AIMessage` with the *same* id and fixed `tool_calls`, and pass it. To delete instead, pass a `RemoveMessage(id=...)` (from `langchain_core.messages`) and the reducer removes the matching entry. Custom reducers behave the way you wrote them — if a channel accumulates with `operator.add`, `update_state` accumulates too. Knowing your own schema's reducers is a prerequisite for editing state safely. ## as_node and what runs next A checkpoint is not only values; it also records which node produced them, and that determines what the graph schedules when it continues. `as_node="agent"` says "pretend the agent node just returned this", so the run continues along the agent node's outgoing edges — including any conditional edge, which will now be evaluated against your edited state. If you omit `as_node`, LangGraph infers the last node that wrote to the channels; that works when it is unambiguous and raises when it is not (for instance after a parallel fan-out). Being explicit is the safer habit, and it is also a tool: setting `as_node` to a different node is how you redirect a paused run down another path without touching its topology. ## The canonical approval-with-edit flow 1. Compile with a checkpointer and `interrupt_before=["tools"]`, or call `interrupt()` in a gate node. 2. Run until it pauses. Read `snapshot = graph.get_state(config)`; `snapshot.values` holds the conversation, `snapshot.next` names the node about to run. 3. Show the reviewer the proposed tool call from the last `AIMessage`'s `tool_calls`. 4. On "edit": build a replacement `AIMessage` with the same `id` and corrected `tool_calls`, call `update_state(config, {"messages": [fixed]}, as_node="agent")`. 5. Continue with `graph.invoke(None, config)` — the tool node now executes the corrected call. For a rejection you typically do not edit the tool call at all; you append a `ToolMessage` whose content explains the refusal and matches the pending `tool_call_id`, so the model sees a legitimate tool result and can re-plan rather than choking on an unanswered call. ## update_state versus Command(resume=...) They are complementary, not alternatives. `Command(resume=value)` delivers a value *into a waiting `interrupt()` call* — it is the answer to a question the node asked. `update_state` rewrites the graph's state from outside, independent of whether anyone asked. Use resume for the decision, use `update_state` for the correction, and remember that `Command` can also carry an `update` payload when you want both in one call. ## Operational notes Every `update_state` writes a checkpoint, so the pre-edit state stays in the thread's history — a genuine audit trail of what the model proposed versus what the human sent. That is worth designing for: record who made the edit somewhere durable, because the checkpoint records the values, not the actor. And be careful editing a thread that is not paused; state written under a concurrently running graph competes with the nodes' own writes and is a good way to produce a state nobody intended.

  • Why does passing a new AIMessage to update_state usually append instead of replacing?
    Because message channels are annotated with the `add_messages` reducer, and `update_state` routes your values through the schema's reducers exactly as a node return would. `add_messages` appends by default and only overwrites when the incoming message's `id` matches an existing one. So a corrected message must reuse the original id, and a deletion is expressed as a `RemoveMessage` with that id rather than by trying to write a shorter list.
  • What is the effect of choosing a different as_node than the one that actually ran?
    You redirect the run. The checkpoint records that node as the producer, so when execution continues LangGraph follows that node's outgoing edges and re-evaluates its conditional edge against your edited state. It is a deliberate escape hatch for pushing a stuck thread down another path, but it is also easy to abuse — the resulting trace shows work attributed to a node that never executed, which confuses later debugging.
  • How do you handle a rejected tool call so the model does not see a dangling request?
    Append a `ToolMessage` whose `tool_call_id` matches the pending call and whose content states that a human declined and why, then continue the run. Chat models require every requested tool call to be answered; leaving one unanswered produces provider errors or bizarre re-planning. Feeding the refusal back as a normal tool result also gives the model something actionable — it can propose a different action instead of retrying the blocked one.

saying these in an interview costs you the question

  • Expects update_state to overwrite a message list wholesale
  • Ignores as_node and is surprised by which node runs next
  • Edits state on a thread while the graph is actively running
  • Thinks update_state and Command(resume=...) are interchangeable
  • Rejects a tool call without answering its tool_call_id

context