How-to › Use AI to do the integration

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.

Audience
Agent builder
Level
intermediate
Topic
Build an agent that calls APIs
Languages
Python and TypeScript
Verified

Your agent reads a ticket in one turn, and the user approves the edit in the next, after the process has restarted. The second turn has lost the ticket’s id and the version it read. It either asks the model to find them in the transcript or writes without them, and a change someone else made in between is overwritten.

What you get

You will end up with a LangGraph graph that reads, edits, and writes back a record across turns and restarts, keeping ids and versions in the state it saves. This is for you if your agent’s API work spans more than one turn.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
External store keyed by conversation idYou already run a database, and a few values must outlive a turnEvery field is a column you write, read and migrate in your own codeThe agent pauses inside a multi-step task and must resume at the same step
LangGraph checkpointerMulti-step work that pauses between turns and must resume after a restartA state schema to migrate whenever the tools change, and checkpoints to pruneEach request finishes in one turn
Message history as the only stateShort conversations where the model can find the ids in what it already saidEvery response is resent each turn, and ids sit in prose the model must quote backThe 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.

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

    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 as the only thing the processes share.

PYTHONPATH=vendor python3 agent_graph.py
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 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.

PYTHONPATH=vendor python3 compare.py
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 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.

export const TicketState = Annotation.Root({
  ticketId: Annotation(),
  etag: Annotation(),
  priority: Annotation(),
  changes: Annotation(),
  outcome: Annotation(),
})
node demo.mjs
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 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.

PYTHONPATH=vendor python3 test_agent_graph.py
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.

node --test graph.test.mjs
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 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.

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.

Footnotes

  1. RFC 6585 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. ↩︎ Back to text

  2. The bytes vary by a byte or two between runs unless something holds them still. The SQLite saver 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.