StateJar docs

Save tokens

Send the last few turns plus StateJar's memory_context as the system message, not the whole history. How to do it in Python and Node, how to count the tokens it saves, and the measured result, which is negative on short chats.

The short answer

The code

Python, with the statejar package from the Python SDK page:

import os
import httpx
from statejar import StateJar

sj = StateJar(session_tag="user-42")

# Your app keeps the chat in whatever message format your model provider uses.
history = [
    {"role": "user", "content": "I'm Meera from Pune and I prefer email."},
    {"role": "assistant", "content": "Nice to meet you, Meera. I'll keep to email."},
    {"role": "user", "content": "My budget is ₹3,500."},
    {"role": "assistant", "content": "Noted: a budget of ₹3,500."},
    {"role": "user", "content": "I'm travelling to Goa in December."},
    {"role": "assistant", "content": "Goa in December, got it."},
]
for m in history:
    if m["role"] == "user":
        sj.ingest(m["content"])                 # StateJar keeps the facts, not the transcript

question = "What is my budget?"
history.append({"role": "user", "content": question})

N = 6                                           # how many of the latest messages to still send
memory = sj.recall(question, audit=True)        # only the fields this question needs

# What goes to YOUR model: StateJar's context as the system message + the last N messages,
# instead of the whole history:
messages = [{"role": "system", "content": memory["memory_context"]}, *history[-N:]]
# reply = client.chat.completions.create(model="...", messages=messages)   # your provider here

# Count it: input tokens (tiktoken cl100k_base) of the full history vs what you send now.
base = os.environ.get("STATEJAR_BASE_URL", "https://api.statejar.com/api/v1").rstrip("/")
body = {"messages": history, "memory_context": memory["memory_context"], "window": N}
t = httpx.post(f"{base}/memory/token-compare", json=body, headers={"X-API-Key": sj.api_key}).json()
print(f"full history: {t['full_history_tokens']} tokens")
print(f"last {N} + StateJar: {t['tokens_with_statejar']} tokens ({t['recent_tokens']} recent + {t['context_tokens']} memory)")
print(f"saved: {t['saved_pct']}%   (negative while the chat is short)")

Node, with the statejar package from the Node SDK page:

import { StateJarClient } from "statejar";

const baseUrl = process.env.STATEJAR_BASE_URL ?? "https://api.statejar.com/api/v1";
const sj = new StateJarClient({ baseUrl });   // STATEJAR_API_KEY is read automatically in Node
const tag = "user-42";

// Your app keeps the chat in whatever message format your model provider uses.
const history = [
  { role: "user", content: "I'm Meera from Pune and I prefer email." },
  { role: "assistant", content: "Nice to meet you, Meera. I'll keep to email." },
  { role: "user", content: "My budget is ₹3,500." },
  { role: "assistant", content: "Noted: a budget of ₹3,500." },
  { role: "user", content: "I'm travelling to Goa in December." },
  { role: "assistant", content: "Goa in December, got it." },
];
for (const m of history) {
  if (m.role === "user") await sj.ingest({ sessionTag: tag, text: m.content }); // facts, not the transcript
}

const question = "What is my budget?";
history.push({ role: "user", content: question });

const N = 6;                                   // how many of the latest messages to still send
const memory = await sj.recall({ sessionTag: tag, query: question, audit: true });

// What goes to YOUR model: StateJar's context as the system message + the last N messages,
// instead of the whole history:
const messages = [{ role: "system", content: memory.memory_context }, ...history.slice(-N)];
// const reply = await yourModel(messages);    // your provider here

// Count it: input tokens (tiktoken cl100k_base) of the full history vs what you send now.
const res = await fetch(`${baseUrl}/memory/token-compare`, {
  method: "POST",
  headers: { "X-API-Key": process.env.STATEJAR_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ messages: history, memory_context: memory.memory_context, window: N }),
});
const t = await res.json();
console.log(`full history: ${t.full_history_tokens} tokens`);
console.log(`last ${N} + StateJar: ${t.tokens_with_statejar} tokens (${t.recent_tokens} recent + ${t.context_tokens} memory)`);
console.log(`saved: ${t.saved_pct}%   (negative while the chat is short)`);

Both run against a real account and print three lines: the full history in tokens, what you now send (recent turns plus memory), and the saving. POST /memory/token-compare does the counting on the server with the same tokenizer as the benchmark, reads and writes no memory, calls no model and stores nothing you send it. The Playground shows the same numbers live, turn by turn, as you chat.

Choose N from your own chats. A larger N keeps more of the conversation's wording but spends more tokens.

Measured on the demos

Run on 31 October 2026 by backend/benchmarks/window_demo.py (the same pipeline, demo conversations and tokenizer as backend/benchmarks/benchmark.py; the committed output is window_results.json and window_results.md beside it). No model is called, so the conversations hold the user's turns only: each turn is rendered as the line user: followed by its text, with no assistant replies and no system prompt on either side. "Last N + StateJar" is the memory context for that turn plus the last N user turns, the current one included. Savings are not clamped: a negative figure means StateJar sent more.

Total input tokens over the whole conversation (every turn's request added up):

ConversationFull historyLast 1 + StateJarLast 3 + StateJarLast 5 + StateJar
17-turn demo, total input tokens2,1693,749 (-72.8%)4,165 (-92.0%)4,541 (-109.4%)
40-turn demo, total input tokens9,8749,506 (3.7%)10,401 (-5.3%)11,247 (-13.9%)

The 17-turn demo is a loss for every N, and the 40-turn mixed demo is a small loss for N=3 and N=5 over the whole conversation. Per turn, at N=3, the 17-turn demo:

TurnFull historyMemory contextLast 3 + StateJarSaved
119112131-589.5%
578180223-185.9%
9128221253-97.7%
13183240286-56.3%
17226254287-27.0%

Turn 17: the full history is 226 tokens and the memory context alone is 254, so the history has not yet grown past the block that replaces it. At N=3 the 40-turn demo:

TurnFull historyMemory contextLast 3 + StateJarSaved
119112131-589.5%
10138215246-78.3%
20237248277-16.9%
263082693070.3%
3034527630511.6%
3439012816258.5%
4046728732430.6%

At N=3 the per-turn saving is positive from turn 26 of the 40-turn demo to its end (25 at N=1, 29 at N=5), reaching 30.6% at turn 40. The memory context shrinks at turns 34 to 36 because those turns ask about one field, and StateJar sends only the fields a question needs once the state is large.

What this does not show: how the saving behaves with assistant replies, which these runs leave out, or with your own prompts; for that, count your own chat. These conversations are terse, form-like logs (12 to 13 tokens a turn on average), a short-message extreme. A percentage means nothing without its baseline: here it is the full history of the same turns.

Questions

Why is the saving negative at first? The memory block is sent every turn and costs more than the first few messages it replaces. A saving needs a history longer than the block plus your N turns.

Does this lose context? The last N turns still go to the model word for word. Older turns reach it only as the facts StateJar stored from them, so something said once and never stored as a fact is gone from the request. Raise N if your chats lean on exact wording.

Do I still need audit=True? It writes the disclosure to your audit log, so you can show later what the model was sent. It does not change what is sent.

Where do the numbers in the Playground come from? From the same call, with the chat you see on screen, including the assistant's replies.

This page as Markdown: /docs/save-tokens.md