Skip to content

The Store

The Store is where swarmstate keeps state. Think of it as a fast, in-memory dictionary with two differences that matter for agent systems:

  1. Keys have two parts - a namespace and a key - so you can group related state (one namespace per workflow, agent, or thread) instead of inventing string prefixes.
  2. Values are stored as msgpack bytes, a compact binary format that any language can read. That's what makes state portable across frameworks and what lets snapshots and checkpoints be cheap.

Everything else in swarmstate - snapshots, the LangGraph checkpointer, the Redis backend - is built on top of the Store.

import swarmstate as ss

store = ss.Store()

# set(namespace, key, value)
store.set("workflow", "onboarding", {"step": 3, "data": {"user": 42}})

# get(namespace, key)
store.get("workflow", "onboarding")          # -> {"step": 3, "data": {"user": 42}}

Creating a store

Store() with no arguments is an in-memory store using the msgpack codec - the right choice for most cases.

store = ss.Store()                           # in-memory, msgpack (defaults)
store = ss.Store(max_history=100)            # also keep the last 100 snapshots
Parameter Default Meaning
backend "memory" where state lives. "memory" today; for persistence use DiskStore, RedisStore or PostgresStore.
codec "msgpack" how values are serialized. msgpack is stable and cross-language.
max_history 0 how many snapshots the store retains for you: 0 none, n the last n, None unlimited. See retention — a retained snapshot pins the state it saw, so the default keeps none.

Reading and writing

The four everyday operations. Note that get returns None (or your default) for a missing key rather than raising - handy for "read state if it exists" flows.

store.set("agents", "researcher", {"status": "running", "tokens": 1200})

store.get("agents", "researcher")            # -> {"status": "running", "tokens": 1200}
store.get("agents", "missing")               # -> None
store.get("agents", "missing", default={})   # -> {}   (supply your own fallback)

store.contains("agents", "researcher")       # -> True
store.delete("agents", "researcher")         # -> True  (False if nothing was there)

Batch reads and writes

When you have many entries, set_many and get_many do the whole batch in one call: the in-memory core releases the GIL once and locks each shard once for the batch (not once per item), and the persistent backends turn it into a single round-trip (a Redis pipeline, one SQL statement). get_many preserves input order and returns None for missing keys.

store.set_many([
    ("agents", "researcher", {"status": "running"}),
    ("agents", "writer", {"status": "idle"}),
    ("workflow", "main", {"step": 3}),
])

store.get_many([("agents", "researcher"), ("agents", "missing"), ("workflow", "main")])
# -> [{"status": "running"}, None, {"step": 3}]

This is worth it for bulk loads and for networked backends, where the per-call round-trip dominates. On a free-threaded build the win is larger still (see Architecture).

Inspecting what's stored

store.keys("agents")        # keys within one namespace  -> ["researcher", ...]
store.namespaces()          # every namespace             -> ["agents", "workflow", ...]
"agents" in store           # does this namespace hold anything?
len(store)                  # total (namespace, key) entries
store.clear()               # wipe everything (snapshots you already took are kept)

Namespaces are usually structured (tenant:thread:...), so both listings can filter inside the store instead of copying every name out first — which matters once there are thousands of them:

store.namespaces(prefix="wf:t42:")   # only this thread's namespaces
store.keys("wf", prefix="task-")     # only keys under that prefix
store.max_key("wf")                  # greatest key, without building a list

The match is literal: a _, %, * or [ inside a thread id is never treated as a wildcard, on any backend.

What you can store

Values go through the msgpack codec, which supports the JSON-like types plus bytes and tuple:

None, bool, int (64-bit), float, str, bytes, list, tuple (comes back as a list), and dict - nested arbitrarily.

store.set("bin", "blob", b"\x00\x01\xff")    # bytes are preserved byte-for-byte
store.get("bin", "blob")                     # -> b"\x00\x01\xff"

Anything else (a custom object, a set, a NumPy array) raises TypeError, and integers beyond the 64-bit range raise ValueError - so serialization problems surface immediately at set() time, not later.

Nesting is capped at 128 levels: deeper than that, or self-referential, raises ValueError. The walkers recurse, and an unbounded one would exhaust the native stack and take the interpreter down with it — an error is always the better outcome.

Why it's safe to share across threads

Agent systems are often concurrent (parallel tool calls, worker pools). The Store is thread-safe, and - because the hot work happens in Rust with the GIL released - many Python threads can read and write the same store genuinely in parallel, instead of queuing behind the interpreter lock:

import threading

store = ss.Store()

def worker(tid):
    for i in range(1000):
        store.set(f"t{tid}", str(i), {"tid": tid, "i": i})

threads = [threading.Thread(target=worker, args=(t,)) for t in range(8)]
for t in threads:
    t.start()
for t in threads:
    t.join()

len(store)   # -> 8000, with no locking on your side

On standard CPython the GIL still serializes the Python-side work, so this is about correctness and not blocking, more than raw multi-core speed. On a free-threaded (no-GIL) build the same code keeps its throughput as threads grow instead of collapsing under the GIL (about 10x the GIL build at 8 threads): see Architecture: free-threaded.

Where to go next