# How to build a stateful API agent with LangGraph

> Read a record in one turn and write it back in the next, after a restart, carrying only its id and version in the state LangGraph saves at each step.

Source: https://voxgig.com/howto/stateful-api-agent-with-langgraph

- Audience: agent-builder
- Level: intermediate
- Languages: python, typescript
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Model the integration as a LangGraph graph whose nodes call the API, compile it with a persistent `checkpointer`, and give each conversation its own thread id. Keep only ids and versions in the state, such as the record id and the `ETag` it was read at, so a later turn can write back with If-Match after a restart. Keep whole API responses out of the state, because every checkpoint stores another copy.

---
## You will need

Python 3.10 or later, Node 22 or later for the TypeScript twin, and a REST API with read and
write operations. Verified 2026-10-15 against Python 3.13.16, LangGraph 1.2.15,
langgraph-checkpoint-sqlite 3.1.2, Node 22.22.0 and `@langchain/langgraph` 1.5.0. `npm ci` in the
sample directory also installs LangGraph for Python under `vendor/`, so the Python commands carry
`PYTHONPATH=vendor`. A scripted model stub stands in for the model, so no provider is called, and
the ticket API is a local server the commands start.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [External store keyed by conversation id](https://www.sqlite.org/lang_upsert.html) | You already run a database, and a few values must outlive a turn | Every field is a column you write, read and migrate in your own code | The agent pauses inside a multi-step task and must resume at the same step |
| [LangGraph `checkpointer`](https://docs.langchain.com/oss/python/langgraph/persistence) | Multi-step work that pauses between turns and must resume after a restart | A state schema to migrate whenever the tools change, and checkpoints to prune | Each request finishes in one turn |
| [Message history as the only state](https://developers.openai.com/api/docs/guides/conversation-state) | Short conversations where the model can find the ids in what it already said | Every response is resent each turn, and ids sit in prose the model must quote back | The write must carry a version that a misquote would break |

The `checkpointer` resumes a paused run at the right node with no code of yours and charges you a
schema: each key in the state is a contract with every paused thread. An external store keeps the
contract small and explicit, and you write the persistence code. Message history needs no store at
all, until the transcript is the store and every turn pays to read it again.

## Keep ids and versions in the state

The state holds what the next turn needs to act: the ticket id, the `ETag` it was read at, and the
proposed change. The response body is used inside the node and dropped.

```python title="agent_graph.py"
class State(TypedDict, total=False):
    """What survives between turns: ids and versions, never whole responses."""

    ticket_id: str
    etag: str
    priority: str
    changes: dict
    outcome: str
```

The read node stores the `ETag`, and the write node sends it back as `If-Match`. A failed read
clears it, so a version left by an earlier ticket in the same thread never reaches a write. A
write without a version is refused in the client, because RFC 6585's `428 Precondition Required`
is a status the server may or may not send.[^1]

```python title="agent_graph.py"
    def read(state):
        status, etag, body = call("GET", f"{base}/tickets/{state['ticket_id']}")
        if status != 200:
            # Clear the version as well: one left by an earlier ticket in this thread would let the
            # next write go out with another record's ETag.
            return {**{k: None for k in version_keys}, "outcome": f"{status}: {body['error']}"}
        return {**{k: etag for k in version_keys}, "priority": body["priority"]}

    def plan(state):
        # Scripted: the edit a model proposes for "raise t7 to high". No provider is called.
        return {"changes": {"priority": "high"}}

    def write(state):
        version = next((state[k] for k in version_keys if state.get(k)), None)
        if not version:
            # Without the version it read, the agent cannot prove nobody changed the ticket since.
            return {"outcome": "not written: no version in state"}
        headers = {"if-match": version}
        status, etag, body = call("PATCH", f"{base}/tickets/{state['ticket_id']}", state["changes"], headers)
        if status != 200:
            return {"outcome": f"{status}: {body['error']}"}
        return {**{k: etag for k in version_keys}, "priority": body["priority"], "outcome": f"priority is {body['priority']} at {etag}"}

    builder = StateGraph(schema)
    builder.add_node("read", read)
    builder.add_node("plan", plan)
    builder.add_node("write", write)
    builder.add_edge(START, "read")
    builder.add_edge("read", "plan")
    builder.add_edge("plan", "write")
    builder.add_edge("write", END)
    # The turn ends after the proposal; the write waits for the user's next message.
    return builder.compile(checkpointer=checkpointer, interrupt_before=["write"])
```

`interrupt_before=["write"]` ends the first turn after the proposal. The next turn calls
`invoke(None, config)` with the same `thread_id`, and LangGraph resumes at `write` from the
checkpoint.

## Resume the turn in another process

The demo starts every turn in a fresh Python process, with a
[SQLite `checkpointer`](https://pypi.org/project/langgraph-checkpoint-sqlite/) as the only thing the
processes share.

```bash
PYTHONPATH=vendor python3 agent_graph.py
```

```text output
turn 1, process 1
  read t7 at "v1", proposed {"priority": "high"}, waiting for the user
turn 2, process 2
  resumed with ticket t7: priority is high at "v2"
turn 1, process 3
  read t7 at "v2", proposed {"priority": "high"}, waiting for the user
  someone else edits t7 between the turns
turn 2, process 4
  resumed with ticket t7: 412: ticket is at v3, you read "v2"
turn 1, process 5
  read t7 at "v3", proposed {"priority": "high"}, waiting for the user
turn 1, process 6
  read t7 at "v3", proposed {"priority": "high"}, waiting for the user
turn 2 of c, process 7, graph with etag renamed to version
  resumed, and nothing ran: the thread is still waiting at ('write',)
turn 2 of d, process 8, the rename as add-then-remove
  resumed with ticket t7: priority is high at "v4"
```

Process 2 had never seen the ticket and wrote it with the right version. Process 4 carried the
same state into a ticket someone had changed, and the API answered
[412](https://www.rfc-editor.org/rfc/rfc9110.html#name-412-precondition-failed) rather than
letting the agent overwrite the other edit. Processes 5 to 8 are the schema problem, covered
below.

## Measure what each approach stores

Each turn reads ticket `t7` once, as an agent does when the user asks about it again. The ticket
carries a 40-comment thread, about 7 KB as one response.

```bash
PYTHONPATH=vendor python3 compare.py
```

```text output
bytes for one conversation            resume loads    stored  resume loads    stored
                                                after 1 turn          after 10 turns
LangGraph state, ids and etag                  676     1,566           672    21,618
LangGraph state, whole responses             7,444    15,022        67,649 1,060,613
message history only                         7,795     7,795        77,950    77,950
external store by conversation id               59        59            59        59
ticket t7 as one response: 7,193 bytes
```

With ids in the state, the checkpoint a resume loads stays near 670 bytes however long the
conversation runs. The stored total still grows, because LangGraph keeps a checkpoint per step, and
its [persistence guide](https://docs.langchain.com/oss/python/langgraph/persistence) says to prune
them.[^2]

With whole responses in a list, the latest checkpoint holds all ten copies, and the stored total
passes a megabyte, growing with the square of the turn count. The message history grows by one
response per turn, and the external store holds one row of 59 bytes.

## The same graph in TypeScript

The JavaScript package has the same shape, with the state declared through `Annotation.Root`.

```js title="graph.mjs"
export const TicketState = Annotation.Root({
  ticketId: Annotation(),
  etag: Annotation(),
  priority: Annotation(),
  changes: Annotation(),
  outcome: Annotation(),
})
```

```bash
node demo.mjs
```

```text output
thread a, turn 1: read t7 at "v1", proposed {"priority":"high"}, next: write
thread a, turn 2: priority is high at "v2"
thread b, turn 1: read t7 at "v2", proposed {"priority":"high"}, next: write
  someone else edits t7 between the turns
thread b, turn 2: 412: ticket is at v3, you read "v2"
```

This twin uses `MemorySaver`. The
[JavaScript persistence guide](https://docs.langchain.com/oss/javascript/langgraph/persistence)
says it keeps checkpoints in RAM, so they are lost when the process restarts.

## Check it worked

The Python tests pin the restart, the refused stale write, the refused write without a version,
the version a failed read clears, the stranded thread, and the growth.

```bash
PYTHONPATH=vendor python3 test_agent_graph.py
```

```text output
ok a_second_graph_on_the_same_file_resumes_the_turn
ok nothing_is_written_until_the_next_turn
ok a_stale_etag_is_refused_and_the_other_edit_survives
ok a_turn_that_lost_its_version_refuses_to_write
ok a_failed_read_leaves_no_version_for_the_next_write
ok deleting_a_state_key_strands_a_paused_thread
ok add_then_remove_resumes_the_paused_thread
ok whole_responses_grow_every_checkpoint_and_ids_do_not
```

The TypeScript tests pin the pause, the 412, the refusal to write without an `ETag`, and the
`ETag` a failed read clears.

```bash
node --test graph.test.mjs
```

```text output
1..4
# tests 4
# suites 0
# pass 4
# fail 0
```

## When it goes wrong

Resuming does nothing. A deploy renamed `etag` to `version` and deleted the old key, and the thread
was paused under the old schema. With LangGraph 1.2.15 the resumed run returned the old state,
never ran `write`, and raised no error, as process 7 in the demo shows. The
[backward compatibility guide](https://docs.langchain.com/oss/python/langgraph/backward-compatibility)
lists renaming or removing a state key as a breaking change and prescribes add-then-remove.
Process 8 does that: the schema adds `version`, keeps `etag`, and the write reads whichever is set.

Every checkpoint is larger than the one before. Whole responses went into the state, and each
checkpoint copies all of them. Store the id and the version, and fetch the body again when a node
needs it.

The write is refused with 412. Someone changed the record between the turns, and the state did
its job. Read it again and re-plan rather than retrying with the old version.

Every thread is gone after a restart. The graph was compiled with `MemorySaver`. Use a saver
that persists, such as `SqliteSaver` or a database-backed one.

## When not to do this

Do not adopt LangGraph to carry two values between turns. A row keyed by conversation id holds
them in 59 bytes, and its schema is yours to migrate with the tools you already use.

Do not put tokens, keys, or personal data in the state. Every checkpoint persists them, and pruning
is something you have to schedule.

Do not rename or delete a state key while threads are paused. Add the new key, dual-write it, and
remove the old one after the paused threads have drained.

Do not keep the transcript as the only record of ids and versions for a write that matters. The
write then depends on the model copying the version exactly from an earlier tool result.

## Related how-tos

- [Wire an API client into the OpenAI Agents SDK](/howto/wire-an-api-client-into-openai-agents-sdk)

- [Sanitize tool results before they re-enter the prompt](/howto/sanitise-tool-results-before-reprompting)

- [Require human approval before an agent calls a delete tool](/howto/require-approval-before-a-delete-tool-call)

- [Cache tool results across the steps of one agent run](/howto/cache-tool-results-across-agent-steps)

## Last verified

Verified 2026-10-15 against Python 3.13.16 and 3.12.3, LangGraph 1.2.15, langgraph-checkpoint-sqlite
3.1.2, Node 22.22.0, `@langchain/langgraph` 1.5.0, `@langchain/core` 1.2.18, and Zod 4.6.7. Every output
block is what the command preceding it printed, with a scripted model in place of a provider.

[^1]: [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585.html) introduced 428 in 2012 to stop
the lost update, where a client reads a resource, modifies it, and puts it back after a third
party has changed it. Its security considerations then say the status is optional, and that
clients cannot rely upon its use to prevent lost update conflicts. The specification that created
the safeguard also told clients not to count on it, which is why the client on this page checks for
itself.

[^2]: The bytes vary by a byte or two between runs unless something holds them still. The
[SQLite saver](https://github.com/langchain-ai/langgraph/blob/main/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py)
writes each channel version as a counter padded to 32 digits, a dot, and the output of
`random.random()`, whose printed length is not fixed. `compare.py` seeds the generator so the
table is the same on every run. A version string that is half arithmetic and half dice is
monotonic in the half that counts.