Where State Lives in an Agent Graph

TL;DR

  • Agent workflow state has three tiers: a script variable that lives only inside one run, a file on disk for what must survive between runs, and git as the permanent evidence of what the run did.
  • Dissect a real loop-era state file line by line and almost none of it is tier 2. goal, cap, iteration, and the ## open / ## fixed lists are a loop’s in-run working set, spilled to disk only because each fresh-context iteration had no shared scope. A graph’s orchestration script is that scope, so they become variables.
  • The determinism floor makes it resumable: Date.now() and Math.random() throw inside a workflow script because resume replays it and a cached call must return the same result. Resume-by-runId caches the unchanged prefix; the first edited call and everything after re-runs.

📊 Proof, up front. No benchmark here, a decomposition. Every line of the fixture below, sorted into the three tiers. Almost the whole file lands in tier 1.

Line in the fixtureLooks likeReal tierIn a graph it is
goal:durable config1a constant or a script arg
cap: 5 iterationsdurable config1a while condition or budget guard
iteration: 2saved progress1a loop counter
## opena work record1an array being processed
## fixed lista durable log1 in-run; 3 for the editsan accumulator array; git holds the diffs
## skippeda work record1a filtered array

Part 9 closed on “more ships next in this series” without naming what. Here it is: where your agent workflow state actually lives once you stop looping and start orchestrating. Back in Loop Part 4 you built a plain file to hold a loop’s memory. Here is one we shipped, drafts/lint-sweeper-state.md, whole:

# lint-sweeper state (the loop's memory spine)
goal: flake8 --extend-ignore=E501 scripts/*.py exits 0
cap: 5 iterations
iteration: 2
## open
## fixed
- gen-agent-map.py:19 F401 'os' imported but unused (iter 1)
- gen-dist-metadata.py:23 E301 expected 1 blank line (iter 2)
- gen-dist-metadata.py:27 E301 expected 1 blank line (iter 2)
## skipped

In a graph, almost none of those lines is state. They are variables a loop couldn’t keep, written to a file only because each iteration ran in a fresh context with nowhere else to hold them across a turn. This post sorts every line into one of three tiers and shows which ones survive.

Prerequisites:

Where you are: the loop kept its memory on disk

Read this section as one thing only: the loop’s state file was a correct fix, not a mistake, and a graph removes the constraint behind it rather than correcting an error. A persistent conductor gives the loop the scope it never had, so the file’s bookkeeping mostly turns back into variables. Nothing else about the loop changes.

Loop Part 4 built a whole memory axis around that file: a ledger that moved work from ## open to ## fixed, tagged with the iteration that closed it. Assume all of it was right, because it was. The single thing a graph changes is where that working set is allowed to live, and that one change is the whole story below.

Agent workflow state lives in three tiers

Agent workflow state lives in three tiers: a script variable that exists only inside one run, a file on disk for what must survive between runs, and git as the permanent evidence of what the run did. Most of what a loop wrote to disk belongs in the first tier, not the second. That is the reclassification this whole post performs.

Tier 1 is a variable in the orchestration script: loop counters, work queues, accumulators, the results of parallel() and pipeline(). It lives for one run and then it is gone. Tier 2 is a file that must cross a run boundary: something a later, separate run has to read. Tier 3 is git: the committed diff, the audit trail a human reads next quarter. Lifetime is the only axis that matters here, and it sorts cleanly.

TierLifetimeWhere it livesFixture example
1one runa script variableiteration, ## open, ## fixed (live)
2across runsa file on diska resume checkpoint (harness-owned)
3forevergitthe actual code edits behind ## fixed

LangGraph’s docs and the agent-memory literature treat this as infrastructure you configure. LangGraph makes state one shared object that flows through the nodes and gets persisted by a checkpointer (SQLite, Redis, Postgres) so runs resume. The Redis agent-memory writing splits short-term working memory from long-term memory in a vector store. Both are correct, and both are real tier-2 (and long-term) mechanisms. Steelman them: a checkpointer is exactly tier 2 done as infrastructure, and it is genuinely useful. What none of them asks is which of your state was ever tier 2 at all. That is the question the fixture answers.

Most of the sweeper file was never state

Almost none of the fixture is durable state. goal, cap, iteration, and the three ## lists are a loop’s in-run working set, written to disk only to cross a turn boundary. Tag each line against the three tiers and the pattern is unmistakable: tier 1 nearly all the way down, with one edge into git.

Here is the same file, annotated with where each line goes in a graph:

goal: flake8 ... exits 0 # tier 1: the target, a constant or a script arg
cap: 5 iterations # tier 1: a loop bound, a while-condition
iteration: 2 # tier 1: a loop counter, on disk only to cross a turn
## open # tier 1: the work queue, an array being processed (empty now)
## fixed # tier 1 live: an accumulator array...
- gen-agent-map.py:19 ... # ...but each fix itself is tier 3: git shows the diff
- gen-dist-metadata.py:23 ...
- gen-dist-metadata.py:27 ...
## skipped # tier 1: a filtered array

Walk it. goal is the target the run drives toward, a constant or an argument you pass in. cap: 5 iterations is a budget guard, a while condition. iteration: 2 is the one that gives the game away: it is a loop counter, on disk for the single reason the opening named, to carry the count across a turn boundary. ## open is a work queue, an array you filter down as you go. ## fixed is an accumulator while the run is live, though the fixes themselves, the real code edits, are tier 3, and git already records those diffs. ## skipped is a filtered array.

The punchline, said once: this file is a loop’s working set spilled to disk, not durable state. The one genuinely cross-run thing a loop needs, a resume checkpoint, is in a graph handled by the harness’s own journal, not by a hand-written ## fixed block.

Verify: take your own state file and ask, line by line, “does a later separate run have to read this, or is it just this run’s scratch space?” Everything that is scratch space is tier 1.

A graph moves that working set into variables

A graph moves that state into variables because its orchestration script is a single persistent scope across every node in one run. The working set the loop had to write to disk now lives in ordinary variables. The conductor holds it; the nodes stay small.

The loop had no conductor. Each iteration was a fresh context, so any value that had to outlive a turn went to disk, because disk was the only thing that outlived the turn. A graph inverts that. The workflow script runs start to finish in one async scope, and every intermediate result lands in a plain variable: Part 3’s 43 fan-out returns land in an array, Part 4’s reduce output is an object, Part 9’s seen-Set is a Set. None of them touches disk, and none of them re-runs to be re-read.

Illustrative of tier 1, the three prior parts collapsed into one scope:

// one run, one script scope: results live in variables, never on disk
const returns = await parallel(files.map(f => () => agent(reviewPrompt(f)))); // 43 objects, in a variable
const summary = reduce(returns); // still a variable
const seen = new Set(); // the cycle's memory, a variable too

Every value the loop would have written to ## open, ## fixed, or iteration is one of these bindings now. The file had them because it had no scope to hold them; the script is that scope.

Intermediate results living in variables instead of a context window is the actual reason a fleet scales. The conductor holds the working set; the nodes stay small, each one seeing only its own inputs. That small-node property is the same reason the loop needed disk in the first place: a fresh context per node has no memory of the last one, so the memory has to live one level up, in the script.

Verify: find the value in your loop that only exists to survive to the next iteration. In a graph, that value is a variable in the enclosing scope, and the disk write disappears with it.

What still has to touch disk, and git

Tier 2 is smaller, not empty. Only what must cross a run boundary belongs on disk, and even resume is handled by the harness’s own journal, not by hand-rolled ## fixed bookkeeping. Tier 3 is git: the committed fixes, the record a human audits later. Do not read this post as “graphs need no disk,” because two real things still land there.

The first is deliberate cross-run artifacts: a report you emit for a separate downstream run to consume, a cache you want to survive on purpose. That is genuine tier 2, and you write it because you decided a later run needs it, not because the current run had nowhere else to put a counter. The second is resume. Our harness records each node’s actual return value in a journal file (journal.jsonl), and that journal is the resume state. It is harness-owned and disk-backed, and it replaces every reason you used to hand-write progress to a file. One line of it, a single node’s cached return, looks like this:

{"runId":"r1","node":"agent#3","inputHash":"a1b2c3","return":{"file":"gen-agent-map.py","fixed":true}}

That line is what a resume reads back instead of re-running the node. It is the one thing the loop’s hand-written ## fixed block was reaching for, now written by the harness, keyed on the call’s inputs rather than on a counter you maintained.

This is exactly where LangGraph’s checkpointer sits, and it is fair to say so: the checkpointer is tier 2 built as infrastructure, the same job the journal does for us. The honest claim is narrow. The tier is real; most of the loop’s file was just never in it.

Verify: for each line you still want on disk, name the specific later run that reads it. If you cannot name one, it is tier 1 wearing a tier-2 costume.

Why a workflow script can’t call Date.now() or Math.random()

A workflow script can’t call Date.now() or Math.random() because resume replays the script, and a cached call must return the identical result it returned the first time. A wall-clock read or an RNG draw would diverge on replay and break the cached prefix, so the harness makes them throw. This is the determinism floor that makes tiers 1 and 2 safe to resume.

Resume-by-runId works on a prefix. Relaunch with a prior run’s id and the harness returns cached results for every completed node call whose inputs (prompt, options) are unchanged. The longest unchanged prefix of calls returns cached instantly; the first edited or new call, and everything after it, re-runs live. Same script plus same args is a 100% cache hit. That only holds if replaying the script produces the same calls in the same order, which a clock read or a random draw would wreck on the spot.

So the built-ins that would diverge throw. Date.now(), Math.random(), and argless new Date() are unavailable inside a workflow script. Standard JSON, Array, and the rest of Math are fine. The workarounds prove the rule rather than dodge it: pass timestamps in through args, stamp results after the workflow returns, and for randomness vary the agent prompt or label by index. State that cannot be replayed identically cannot be resumed, so the floor is not a limitation, it is what makes the cache trustworthy.

Try It Now. Three steps, from a plain-node demo to the harness contract.

  1. See why replay can’t read a clock, in plain node with no harness. Cache one read, then compare the cached value against a genuinely fresh read:
// replay-vs-live.js (run: node replay-vs-live.js)
let ticks = 0;
const readClock = () => ++ticks; // stands in for Date.now(): every real read is new
const cache = new Map();
const memo = (k, fn) => cache.has(k) ? cache.get(k) : (cache.set(k, fn()), cache.get(k));
const first = memo('t', readClock); // first read, computed and cached
const replay = memo('t', readClock); // replay: returns the cached value, never re-reads
const live = readClock(); // a genuinely fresh read
console.log({ first, replay, live });

Verify: it prints { first: 1, replay: 1, live: 2 }. The replayed read returns the cached 1 while a fresh read returns 2, so a script that read a live source would diverge the instant resume replayed it. That divergence is what the cache cannot tolerate. 2. The harness contract that follows: inside a workflow script Date.now(), Math.random(), and argless new Date() throw. Inject time and vary randomness by index instead of reading them. Illustrative of the contract, not a snippet to paste:

// illustrative of the harness contract, not a runnable API
const t = args.now; // time comes in through args
const draws = files.map((f, i) => () => agent(prompt(f), { seed: args.seed + i })); // randomness varies by index
  1. The resume mental model to expect: relaunch a run with resumeFromRunId and the harness returns cached results for the longest unchanged prefix of calls; edit one node and that call plus everything after it re-runs live. Same script and same args is a full cache hit. This is the behavior to expect, not a call you copy.

Verify: the plain-node demo above is real behavior you can run; the harness throw enforces the same divergence at the source. This post prices nothing and benchmarks nothing; it points at the mechanism.

FAQ

Where should agent workflow state live?

In three tiers, defaulting to the first. A script variable for anything used within one run (counters, queues, accumulators, node results). A file on disk only for what a later separate run must read. Git for the permanent record of what the run did. Most of a loop-era state file is tier 1, so it becomes variables the moment a persistent orchestration script exists.

Do I still need a state file if I use a graph?

Mostly no. A persistent orchestration script holds the in-run working set in variables, so the counters and queues a loop wrote to disk no longer go there. Disk is for cross-run survival only, and resume itself is the harness’s journal, not something you hand-write. The residue is small and deliberate.

Why are Date.now() and Math.random() unavailable in a workflow script?

Because resume replays the script, and a cached call must return the same result it did the first time. A clock read or an RNG draw would diverge on replay and break the cached prefix, so the harness makes them throw. Inject time through args, stamp it after the workflow returns, and vary randomness by agent index.

What does resume-by-runId cache?

Completed node calls whose inputs (prompt, options) are unchanged. The longest unchanged prefix returns cached instantly; the first edited or new call, and everything after it, re-runs live. Same script and same args is a 100% cache hit.

Was the loop’s state file wrong?

No. It was the right fix when each iteration ran in a fresh context with no shared scope, and disk was the only memory that survived a turn. A graph gives you that scope, so most of the file becomes variables. The pattern narrows; it was not a mistake.

What could go wrong, and what’s next

You can now take any line of your own agent workflow state and sort it into one of three tiers: a variable inside one run, a file for what crosses runs, git for what must be proven later. Run that sort and most of a loop-era file collapses into variables. Two pitfalls to watch. First, do not overcorrect into “no disk ever”: a deliberate cross-run artifact and the resume journal are genuine tier 2, and git is a real tier, so name what legitimately stays. Second, do not fight the determinism floor: if a node needs a timestamp or a random seed, pass it in through args rather than reaching for Date.now(), or you break the cached prefix you were trying to keep. More ships next in this series.

{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Where State Lives in an Agent Graph",
"author": { "@type": "Organization", "name": "ShipWithAI" },
"datePublished": "2026-08-20",
"description": "Agent workflow state has three tiers: variables in a run, a file between runs, git forever. The loop's disk bookkeeping mostly becomes variables.",
"image": "/images/blog/graph-state-in-a-graph-cover.png",
"articleSection": "tutorial",
"keywords": "claude-code, automation, ai, tutorial, english",
"mainEntityOfPage": "https://shipwithai.io/blog/graph-state-in-a-graph/"
}
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "Where State Lives in an Agent Graph",
"description": "Sort agent workflow state into three tiers, a variable inside one run, a file between runs, git forever, by taking a real loop-era state file apart line by line.",
"image": "/images/blog/graph-state-in-a-graph-cover.png",
"step": [
{
"@type": "HowToStep",
"name": "Where you are: the loop kept its memory on disk",
"text": "The loop's state file was a correct fix, not a mistake. Each iteration ran in a fresh context, and a file was the only memory that survived to the next turn. A graph removes that constraint rather than correcting an error."
},
{
"@type": "HowToStep",
"name": "Agent workflow state lives in three tiers",
"text": "Agent workflow state lives in three tiers: a script variable that exists only inside one run, a file on disk for what must survive between runs, and git as the permanent evidence of what the run did. Most of what a loop wrote to disk belongs in the first tier."
},
{
"@type": "HowToStep",
"name": "Most of the sweeper file was never state",
"text": "Almost none of the fixture is durable state. goal, cap, iteration, and the open/fixed/skipped lists are a loop's in-run working set, written to disk only to cross a turn boundary. Tag each line and it is tier 1 nearly all the way down, with one edge into git."
},
{
"@type": "HowToStep",
"name": "A graph moves that working set into variables",
"text": "A graph's orchestration script is a single persistent scope across every node in one run, so the working set the loop wrote to disk now lives in ordinary variables. The conductor holds it; the nodes stay small."
},
{
"@type": "HowToStep",
"name": "What still has to touch disk, and git",
"text": "Tier 2 is smaller, not empty: only what must cross a run boundary belongs on disk, and even resume is the harness journal, not hand-rolled bookkeeping. Tier 3 is git, the committed fixes a human audits later."
},
{
"@type": "HowToStep",
"name": "Why a workflow script can't call Date.now() or Math.random()",
"text": "Resume replays the script, and a cached call must return the identical result it returned the first time. A wall-clock read or an RNG draw would diverge on replay and break the cached prefix, so the harness makes them throw. Inject time via args, vary randomness by index."
},
{
"@type": "HowToStep",
"name": "What could go wrong, and what's next",
"text": "Sort every line of your own agent workflow state into one of three tiers. Do not overcorrect into no disk ever (cross-run artifacts and the resume journal are genuine tier 2), and do not fight the determinism floor: pass a needed timestamp or seed through args instead of reading it."
}
]
}
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Where should agent workflow state live?",
"acceptedAnswer": {
"@type": "Answer",
"text": "In three tiers, defaulting to the first. A script variable for anything used within one run (counters, queues, accumulators, node results). A file on disk only for what a later separate run must read. Git for the permanent record of what the run did. Most of a loop-era state file is tier 1, so it becomes variables the moment a persistent orchestration script exists."
}
},
{
"@type": "Question",
"name": "Do I still need a state file if I use a graph?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Mostly no. A persistent orchestration script holds the in-run working set in variables, so the counters and queues a loop wrote to disk no longer go there. Disk is for cross-run survival only, and resume itself is the harness's journal, not something you hand-write. The residue is small and deliberate."
}
},
{
"@type": "Question",
"name": "Why are Date.now() and Math.random() unavailable in a workflow script?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Because resume replays the script, and a cached call must return the same result it did the first time. A clock read or an RNG draw would diverge on replay and break the cached prefix, so the harness makes them throw. Inject time through args, stamp it after the workflow returns, and vary randomness by agent index."
}
},
{
"@type": "Question",
"name": "What does resume-by-runId cache?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Completed node calls whose inputs (prompt, options) are unchanged. The longest unchanged prefix returns cached instantly; the first edited or new call, and everything after it, re-runs live. Same script and same args is a 100% cache hit."
}
},
{
"@type": "Question",
"name": "Was the loop's state file wrong?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. It was the right fix when each iteration ran in a fresh context with no shared scope, and disk was the only memory that survived a turn. A graph gives you that scope, so most of the file becomes variables. The pattern narrows; it was not a mistake."
}
}
]
}
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Blog", "item": "https://shipwithai.io/blog/" },
{ "@type": "ListItem", "position": 2, "name": "Where State Lives in an Agent Graph", "item": "https://shipwithai.io/blog/graph-state-in-a-graph/" }
]
}