My findings:
Why the scratchpad hack “works” but is unsafe
works because it clears the in‑memory resume list before interrupt() looks at it, forcing the runtime to fall back to the “null resume” coming from the current Command. But __pregel_scratchpad is an internal implementation detail used to coordinate per‑task resumes, nested graphs, and subgraph resume mapping (PregelScratchpad in libs/langgraph-core/src/pregel/types.ts and _scratchpad in pregel/algo.ts). Mutating it:
- can desynchronize the scratchpad from the checkpoint’s pending writes and
__pregel_resume_map, especially if there are multiple interrupts in the same node or subgraph
- will almost certainly break if the internal layout changes in a future LangGraph release
- is not needed once you use either resume maps or
updateState/forking
Using the documented resume map (Command({ resume: { interruptId: value } })) or fork + updateState patterns keeps you on the supported surface area and composes cleanly with more complex graphs (parallel interrupts, subgraphs, etc.), while giving you the “time travel and change the human input” behavior you’re after.
So, instead of mutating __pregel_scratchpad, you should (a) time‑travel to the checkpoint you saved at the interrupt, and (b) resume using a resume map keyed by the interrupt ID, or (for this simple graph) fork the state with updateState and change the messages channel. Both approaches are supported by LangGraph and won’t break with subgraphs or parallel interrupts.
This comes from how interrupts and resumes are implemented internally in LangGraph JS:
-
The interrupt helper reads its resume values from an internal scratchpad (__pregel_scratchpad) that is derived from checkpoint pending writes and an optional __pregel_resume_map in the config. See the implementation in libs/langgraph-core/src/interrupt.ts and how _scratchpad is built in libs/langgraph-core/src/pregel/algo.ts (where resume and nullResume are computed from checkpoint writes and resumeMap) for details.
-
When you first call new Command({ resume: new HumanMessage("What is 1+1") }), the runtime writes that resume value into the checkpoint’s pending writes and propagates it into the scratchpad on the next run (PregelLoop._first in libs/langgraph-core/src/pregel/loop.ts).
-
When you later time‑travel using the same checkpoint config, those stored resume writes are still associated with that checkpoint and are read before the new Command.resume you pass, so interrupt continues to return the old value ("What is 1+1"). Your scratchpad hack works because it force‑clears the resume array right before interrupt reads it, but that’s relying on private internals.
Pattern 1: use resume maps keyed by interrupt IDs
LangGraph supports resuming specific interrupts using a resume map: a mapping from interrupt IDs to resume values. This is the pattern shown in the human‑in‑the‑loop docs, where they build a resume_map from state.interrupts and pass it to Command(resume=...) in both Python and JS:
// JS-style example from the docs (simplified)
const state = await parentGraph.getState(threadConfig);
const resumeMap = Object.fromEntries(
state.interrupts.map((i) => [
i.interruptId,
`human input for prompt ${i.value}`,
])
);
await parentGraph.invoke(new Command({ resume: resumeMap }), threadConfig);
In your case you can adapt this pattern as follows:
-
After the first interrupt, capture the checkpoint and interrupt ID.
const threadConfig = { configurable: { thread_id: threadId } };
// Run until the interrupt on human_node
for await (const ev of graph.streamEvents(
{ messages: [new HumanMessage("Hello")] },
{ ...threadConfig, version: "v2" },
)) {
// ...stop when you see the interrupt event...
}
// Get the checkpoints for this thread and pick the one at the human_node interrupt
const history: StateSnapshot[] = [];
for await (const s of graph.getStateHistory(threadConfig)) {
history.push(s);
}
const interruptCheckpoint = history[0]; // or filter by metadata / node if needed
// Extract the interrupt id from that checkpoint
const interrupts = interruptCheckpoint.tasks
.flatMap((t) => t.interrupts ?? []);
const interruptId = interrupts[0]?.id;
-
Normal resume (first time) using a resume map instead of a bare value.
await graph.streamEvents(
new Command({
resume: { [interruptId]: new HumanMessage("What is 1+1") },
}),
interruptCheckpoint.config, // note: use the checkpoint's config
);
-
Later, time‑travel and change that same interrupt’s value.
To “go back” to just before the interrupt and pretend the user had asked "What is 2+2" instead, reuse the same checkpoint and interruptId, but pass a different resume map:
await graph.streamEvents(
new Command({
resume: { [interruptId]: new HumanMessage("What is 2+2") },
}),
{
...interruptCheckpoint.config, // already includes thread_id & checkpoint_id
version: "v2",
signal: abortController.signal,
},
);
Because this uses the documented resumeMap mechanism (__pregel_resume_map internally), it doesn’t rely on or overwrite __pregel_scratchpad directly. The mapping from interruptId → resume value is stable across time‑travel, nested graphs, and parallel interrupts, and the runtime computes the correct per‑task resume sequence from it (see _scratchpad and interrupt() in libs/langgraph-core/src/pregel/algo.ts and libs/langgraph-core/src/interrupt.ts).
Pattern 2: fork and directly edit the human message in state
For your particular graph—where the human node just returns { messages: [nextHumanMessage] }—another straightforward option is to fork the state at the checkpoint and modify the messages channel, as shown in the time‑travel docs:
-
From the checkpoint you saved at the interrupt, call updateState to write a new human message into the state as if human_node had returned it:
// Fork the checkpoint by updating messages as if human_node had run with "What is 2+2"
const forkConfig = await graph.updateState(
interruptCheckpoint.config,
{ messages: [new HumanMessage("What is 2+2")] },
"human_node", // attribute the update to human_node
);
-
Then continue running from that forked checkpoint:
for await (const ev of graph.streamEvents(
null,
{ ...forkConfig, version: "v2" },
)) {
// model_node now sees the updated messages and should answer "4"
}
This pattern is closer to the “forking” example in the JS time‑travel docs (time-travel) where updateState is used to edit a specific checkpoint and then the graph is resumed from the resulting forked checkpoint.
Feel free to try those patterns. I believe there is a more idiomatic way to solve your issue 