Checkpointing and Threads

In shortHow checkpointers save and restore conversation state across calls using thread IDs.

  • 7 min read
  • 12 sections
  • Updated
  • v0.9.2
  • Markdown

By default every app.invoke call is stateless — each call starts with an empty AgentState. A checkpointer adds persistence: state is saved after each node run and restored at the start of the next call for the same thread.

The config dict

Every checkpointer method accepts a config dict. At minimum it must contain a thread_id:

Python
config = {
    "thread_id": "conv-abc123",   # required — identifies the conversation
    "user_id": "user-42",         # optional — used by PgCheckpointer for row scoping
}

thread_id is the primary key for all state, message, and thread-info operations. user_id scopes data within a multi-tenant deployment and is used by PgCheckpointer when you query threads for a specific user.

Thread lifecycle

flowchart LR
  NewCall[invoke with thread_id] --> Check{Thread exists?}
  Check -->|no| CreateState[Create fresh AgentState]
  Check -->|yes| LoadState[Load saved AgentState]
  CreateState --> RunGraph[Run graph nodes]
  LoadState --> RunGraph
  RunGraph --> SaveState[Save updated AgentState]
  SaveState --> Return[Return result to caller]

A thread is created on the first call and persists until you explicitly delete it. For InMemoryCheckpointer data is lost on process restart; PgCheckpointer survives restarts.

Attaching a checkpointer

Pass the checkpointer to graph.compile():

Python
from tenxgraph.storage.checkpointer import InMemoryCheckpointer
from tenxgraph.core.state import AgentState, Message

checkpointer = InMemoryCheckpointer()
app = graph.compile(checkpointer=checkpointer)

Provide thread_id in every call. The first argument is always a dict with a "messages" key — not an AgentState object:

Python
config = {"thread_id": "session-1", "recursion_limit": 10}

# Turn 1
res = app.invoke(
    {"messages": [Message.text_message("What is 2 + 2?")]},
    config=config,
)

# Turn 2 — same thread_id, resumes from saved state
res = app.invoke(
    {"messages": [Message.text_message("Now multiply that by 3.")]},
    config=config,
)

InMemoryCheckpointer

InMemoryCheckpointer stores everything in Python dicts guarded by asyncio.Lock objects. It is async-first — all operations are non-blocking coroutines. Sync wrappers (put_state, get_state, …) run the coroutines via run_coroutine().

Python
from tenxgraph.storage.checkpointer import InMemoryCheckpointer

checkpointer = InMemoryCheckpointer()

Internal storage:

Attribute Type Purpose
_states dict[str, StateT] Current agent state per thread key
_state_cache dict[str, StateT] Lightweight read cache for state
_generic_cache dict[str, (Any, float)] Optional key-value cache with optional TTL
_messages defaultdict[str, list[Message]] Message history per thread key
_threads dict[str, dict] Thread metadata per thread key

Thread key is simply str(thread_id) — there is no user scoping in the in-memory implementation.

Use for: development, unit tests, and short-lived single-process deployments.
Do not use for: anything that requires state to survive a restart or be shared across multiple workers.


PgCheckpointer

PgCheckpointer uses PostgreSQL for durable storage and Redis for a low-latency read cache. It is designed for production multi-worker deployments.

Constructor signature

Python
from tenxgraph.storage.checkpointer import PgCheckpointer

checkpointer = PgCheckpointer(
    # --- PostgreSQL connection (pick one) ---
    postgres_dsn="postgresql://user:pass@localhost/mydb",  # DSN string
    # OR
    pg_pool=existing_asyncpg_pool,                         # pass an existing pool
    # OR
    pool_config={"min_size": 2, "max_size": 10},           # kwargs forwarded to asyncpg.create_pool

    # --- Redis connection (pick one, optional) ---
    redis_url="redis://localhost:6379/0",
    # OR
    redis=existing_redis_instance,
    # OR
    redis_pool=existing_connection_pool,
    # OR
    redis_pool_config={"max_connections": 20},

    # --- Optional tuning ---
    schema="public",          # PostgreSQL schema (default: "public")
    cache_ttl=86400,          # Redis TTL in seconds (default: 24 h)
    user_id_type="string",    # Column type for user_id: "string" | "int" | "bigint"
    release_resources=True,   # Close pools on cleanup
)

Schema migration

On the first call to await checkpointer.asetup() (or checkpointer.setup()) the checkpointer:

  1. Checks if the tables already exist.
  2. Runs any pending migration steps to bring the schema to the current version.
  3. Creates tables if they do not exist.

You must call setup() once before using the checkpointer:

Python
await checkpointer.asetup()
app = graph.compile(checkpointer=checkpointer)

Installing the extra

Terminal
pip install "10xgraph[pg_checkpoint]"

This installs asyncpg and redis[asyncio].


SqliteCheckpointer

SqliteCheckpointer collapses the two-layer model into a single local SQLite file. Durable state, the realtime state cache, messages, threads, and the generic cache all live in one .db file — there is no Redis and no Postgres. I/O is fully async via aiosqlite; a single persistent connection runs in WAL mode with writes serialized behind an asyncio.Lock.

Python
from tenxgraph.storage.checkpointer import SqliteCheckpointer

# Defaults to ~/.10xgraph/checkpointer.db; ":memory:" gives an ephemeral DB.
checkpointer = SqliteCheckpointer("agent_state.db")
app = graph.compile(checkpointer=checkpointer)

Like PgCheckpointer, it reads the state cache table first and falls back to durable state, and it reconstructs custom AgentState subclasses on read via an embedded class path. Data is keyed purely by thread_id; user_id is not required.

Use it for client-side / single-user agents — a desktop app shipping a Python sidecar (Tauri, Electron, PyInstaller), a local CLI agent, or any deployment where each user has their own process and their own database file (one writer per file). Avoid it for shared multi-user servers: SQLite serializes writers and does not scale horizontally — use PgCheckpointer instead.

Install the extra:

Terminal
pip install "10xgraph[sqlite_checkpoint]"

Call await checkpointer.arelease() (or checkpointer.release()) at shutdown to close the connection.


Checkpointer API reference

All methods exist in both async (preferred) and sync (convenience wrapper) flavours. The sync wrappers call run_coroutine() internally.

State methods

Async method Sync method Description
aput_state(config, state) put_state(config, state) Persist the current AgentState for the thread
aget_state(config) get_state(config) Load the saved AgentState; returns None if not found
aclear_state(config) clear_state(config) Delete the persisted state for a thread
aput_state_cache(config, state) put_state_cache(config, state) Write to the fast read cache
aget_state_cache(config) get_state_cache(config) Read from the fast cache (Redis for PgCheckpointer)

The state cache is a thin Redis-backed read layer in PgCheckpointer (TTL-controlled). It avoids a Postgres round-trip on repeated reads for the same thread. For InMemoryCheckpointer both _states and _state_cache live in memory.

Message methods

Checkpointers also store the full message history for a thread independently of the compressed state:

Async method Sync method Description
aput_messages(config, messages, metadata) put_messages(...) Append one or more Message objects
aget_message(config, message_id) get_message(...) Retrieve a single message by ID
alist_messages(config, search, offset, limit) list_messages(...) Paginated and optionally full-text searched message list
adelete_message(config, message_id) delete_message(...) Remove a single message

Thread methods

Thread metadata (ThreadInfo) is stored separately from state:

Async method Sync method Description
aput_thread(config, thread_info) put_thread(...) Create or update thread metadata
aget_thread(config) get_thread(...) Get ThreadInfo for a thread
alist_threads(config, search, offset, limit) list_threads(...) Paginated thread listing scoped to user_id
aclean_thread(config) clean_thread(...) Delete all state, messages, and metadata for a thread

Generic cache methods

An optional key-value cache layer for arbitrary JSON values (e.g., pre-computed embeddings or rendered prompts):

Python
# Store a value with a 10-minute TTL
await checkpointer.aput_cache_value("llm-cache", "prompt-hash-abc", {"response": "..."}, ttl_seconds=600)

# Retrieve it
value = await checkpointer.aget_cache_value("llm-cache", "prompt-hash-abc")

# Delete it
await checkpointer.aclear_cache_value("llm-cache", "prompt-hash-abc")

# List all keys in a namespace
keys = await checkpointer.alist_cache_keys("llm-cache", prefix="prompt-")

InMemoryCheckpointer supports TTL on the generic cache via expiry timestamps. PgCheckpointer uses Redis SETEX / GET / DEL / SCAN for these operations.


Direct checkpointer usage (outside the graph)

You can interact with a checkpointer directly — useful for admin scripts, migrations, or custom REST endpoints:

Python
from tenxgraph.storage.checkpointer import InMemoryCheckpointer
from tenxgraph.core.state import AgentState, Message

cp = InMemoryCheckpointer()
config = {"thread_id": "t1", "user_id": "u1"}

# Save state manually
state = AgentState(context=[Message.text_message("Hello", role="user")])
await cp.aput_state(config, state)

# Load it back
loaded = await cp.aget_state(config)

# List all messages
messages = await cp.alist_messages(config, limit=50)

# Clean the thread
await cp.aclean_thread(config)

Reading thread state via REST

When running behind the API server the same data is exposed over HTTP:

Terminal
# Read current state
GET /v1/threads/{thread_id}/state

# Overwrite state
PUT /v1/threads/{thread_id}/state

# Read message history  
GET /v1/threads/{thread_id}/messages

# List threads for a user
GET /v1/threads

See REST API: Threads for request/response schemas.

Using a checkpointer with the API server

The API server uses the checkpointer the compiled graph carries, so pass it to compile() in the module that agent points at:

Python
# graph/react.py
from graph.dependencies import my_checkpointer

app = state_graph.compile(checkpointer=my_checkpointer)
JSON
{
  "agent": "graph.react:app"
}

10xgraph.json also recognises a checkpointer key, but the server does not apply it yet, so setting it has no effect. See Configure 10xgraph.json.


Choosing a checkpointer

Criterion InMemoryCheckpointer SqliteCheckpointer PgCheckpointer
State survives restart No Yes Yes
Shared across workers No No Yes
Multi-tenant user scoping No No Yes
External dependencies None None (single file) PostgreSQL + Redis
Setup required No No (lazy) await cp.asetup()
Best for Dev, tests Client-side / single-user agents Production multi-user

Last updated for v0.9.2Edit this page on GitHubReport an issue