A working reference implementation that shows how to add persistent, cross-session memory to any Python chat application using Statewave and FastAPI.
Clone it, run it, and see the difference in under five minutes.
Part of Statewave — the open-source memory runtime for AI agents.
📦 Core runtime · 🐍 Python SDK · 🟦 TypeScript SDK · 🔌 Connectors · 📘 Docs · 💡 Examples · 🖥️ Admin · 🌐 statewave.ai
📋 Issues & feature requests: statewave/issues (centralized tracker)
Want to see real compiled memory before setting up an LLM key? This path skips the LLM entirely — it only needs Python and the OSS Statewave server.
# 1. Boot Statewave locally (API + admin console + Postgres) — one line, no clone needed
npx @statewavedev/statewave
# 2. In a new terminal, clone and install this repo
git clone https://github.com/smaramwbc/statewave-personal-assistant.git
cd statewave-personal-assistant
python -m venv .venv && source .venv/bin/activate # .venv\Scripts\activate on Windows
pip install -e ".[dev]"
# 3. Seed two demo users with realistic conversation history
python -m scripts.seed
# 4. Ask Statewave what it knows about one of them — no LLM call, no API key required
curl http://localhost:8000/api/v1/memory/dev_aliceThat last call returns the compiled, ranked memory facts Statewave extracted from dev_alice's
seeded sessions — the same context that would otherwise get injected into an LLM prompt. If you
want to see it actually change an LLM's response, continue to the full Quick Start
below and add an LLM_API_KEY to try POST /api/v1/compare.
Every session-based chat application faces the same wall. When a user returns after closing the tab, the assistant remembers nothing. The standard workaround is to inject the full conversation history into every prompt, but this breaks quickly:
- After 8 to 10 turns, the context window is full
- Earlier facts are silently dropped to make room for recent ones
- The more sessions a user has, the worse the problem gets
- There is no ranking signal: a throwaway comment gets as much weight as a critical bug report
This is not a model limitation. It is an architecture problem.
Statewave is an open-source, self-hosted memory runtime for AI agents. Instead of passing raw conversation history to the LLM, you pass conversations through Statewave, which:
- Extracts structured facts from each conversation turn (user role, procedures, artifact references, session summaries)
- Ranks those facts by relevance and recency
- Returns only the highest-signal context that fits within your token budget, as a ready-to-inject string
The result is an assistant that knows who the user is, what they have been building, and what is still unresolved, across every session, using a fraction of the tokens that raw history would consume.
Statewave is self-hosted (Apache 2.0), runs on PostgreSQL with pgvector, and requires no cloud account or external API key to operate.
If you are new to Statewave, these are the five terms you will encounter throughout this codebase.
An episode is a single recorded conversation turn: one user message and one assistant response. Episodes are the raw input to Statewave. You record them after each turn using POST /v1/episodes.
Think of episodes as the raw log. Statewave reads from this log to build memories.
A memory is a structured fact that Statewave extracts from one or more episodes. Memories are typed:
| Kind | What it stores |
|---|---|
profile_fact |
Stable facts about the user (role, language preference, timezone) |
episode_summary |
A compressed summary of what happened in a session |
procedure |
Multi-step instructions or workflows the user follows |
artifact_ref |
References to external artifacts, files, or documents |
Memories are created by calling POST /v1/memories/compile. This is an explicit step, not automatic.
A subject is the entity whose memory is being tracked. In a chat application, each user has their own subject. The subject_id is just a string you choose, for example dev_alice or user_123. All episodes and memories are scoped to a subject.
When a new message arrives, you ask Statewave for a context bundle via POST /v1/context. Statewave ranks all compiled memories for that subject, selects the highest-scored facts that fit within your token budget, and returns them as a single pre-assembled string called assembled_context. You inject this string directly into the LLM system prompt.
A receipt is an immutable, HMAC-signed audit record of every context assembly call. It captures exactly which facts were returned and why. Useful for debugging and compliance, not required for the core integration.
Every chat request goes through a three-step loop. Two HTTP calls to Statewave wrap the LLM call: one before to fetch ranked context, one after to record the episode.
The integration surface is two API calls: get_context before the LLM call and record_episode after. That is the entire memory layer. See app/services/statewave.py for the implementation.
Before you begin, make sure you have:
- Python 3.11 or newer (download)
- Docker and Docker Compose (needed to run Statewave locally)
- An API key for an OpenAI-compatible LLM provider (OpenAI, Azure OpenAI, local Ollama, etc.)
- Git for cloning repositories
If you are unsure whether you have these installed, run the following in your terminal:
python --version # should print 3.11.x or higher
docker --version # should print Docker version ...
git --version # should print git version ...git clone https://github.com/smaramwbc/statewave-personal-assistant.git
cd statewave-personal-assistant
# Create an isolated Python environment (recommended)
python -m venv .venv
# Activate it
source .venv/bin/activate # macOS / Linux
.venv\Scripts\activate # Windows (Command Prompt)
# .venv\Scripts\Activate.ps1 # Windows (PowerShell)
# Install the application and its dependencies
pip install -e ".[dev]"cp .env.example .envOpen the .env file in any text editor and set your LLM API key:
LLM_API_KEY=your-api-key-here
Everything else has sensible defaults. A full list of available variables is in the Environment Variables section at the bottom of this page.
Statewave is a separate server that runs alongside your application. It's fully open source (Apache-2.0) — pick whichever way you want to run it:
Fastest — one line to a running server:
npx @statewavedev/statewaveThis boots the same open-source Statewave server (API, admin console, and Postgres) locally and wires it into your MCP clients. Equivalent one-liner: curl -fsSL https://www.statewave.ai/install | sh.
Prefer to run it yourself from source? Clone and start it with Docker Compose:
# In a new terminal window or tab, go up one level from this repo
cd ..
git clone https://github.com/smaramwbc/statewave
cd statewave
docker compose up -dWhat is
docker compose up -ddoing? It starts a PostgreSQL database (with the pgvector extension for vector search) and the Statewave API server in the background. The-dflag means "detached" so the containers run without blocking your terminal.
Statewave starts at http://localhost:8100. You can verify it is running:
curl http://localhost:8100/healthz
# Expected output: {"status":"ok"}Go back to this repository's directory:
cd ../statewave-personal-assistantThis step populates Statewave with two fictional developer profiles that have realistic conversation histories. It is what makes the demo useful out of the box.
python -m scripts.seedYou will see output like:
Statewave Memory Quickstart Seed Script
Target: http://localhost:8100
Seeding 2 demo users...
Seeding dev_alice Alice Chen... done
Seeding dev_bob Bob Martinez... done
User ID Name Episodes Memories Compiled Errors
dev_alice Alice Chen 4 12 0
dev_bob Bob Martinez 6 18 0
Done. Open http://localhost:8000 after starting the server.
What are
dev_aliceanddev_bob? Alice is an indie developer building a RAG-based code assistant. Bob is a developer building a Slack bot for his team. Their conversation histories cover realistic LLM API challenges: rate limits, chunking strategies, streaming, function calling, error handling, and token counting. When you chat with the demo, the assistant can reference these past conversations.
uvicorn app.main:app --reloadOpen http://localhost:8000 in your browser.
The --reload flag makes the server restart automatically when you edit source files, which is useful during development.
The chat interface at http://localhost:8000 is designed to make the memory layer visible, not just functional.
+---------------------------+------------------------------+
| dev_alice [switcher] | Memory State [refresh] |
+---------------------------+------------------------------+
| | |
| [chat panel] | [memory panel] |
| | |
| Messages appear here. | Every compiled fact about |
| Each assistant reply | the current user appears |
| shows: | here, grouped by kind: |
| - memory / no memory | |
| - episode ID | profile_fact score: 1.00 |
| - token count used | procedure confidence: 0.94 |
| | episode_summary confidence: 0.87 |
+---------------------------+------------------------------+
| [hint chips] What was the bug I was debugging last... |
+----------------------------------------------------------+
| Type a message... [Send] |
+----------------------------------------------------------+
Left panel (chat): This is where you talk to the assistant. Every reply from the assistant shows a badge indicating whether memory context was used, the episode ID of this turn, and how many tokens the memory context consumed.
Right panel (memory state): This shows every compiled memory fact Statewave has for the selected user. Facts are grouped by kind, sorted by score, and each one shows which episode it came from. This panel refreshes automatically after each assistant reply so you can watch memory accumulate in real time.
User switcher (top bar): Switch between dev_alice, dev_bob, or type any custom user ID. The memory panel updates immediately to show that user's compiled facts.
Hint chips: Pre-written prompts that are good starting points for testing memory recall. Click one to send it directly.
If you prefer the terminal, four commands demonstrate the memory pattern:
python -m scripts.compare compare --user dev_alice \
--message "What was the bug I was debugging last session?"Sends the same message to two assistants simultaneously and prints the responses side by side. The left side has no memory. The right side has Statewave context injected.
User message dev_alice
What was the bug I was debugging last session?
+----------------------------------+ +----------------------------------+
| Stateless | | Statewave-backed |
| No memory | | 3 memories ~280 tokens |
| | | |
| I don't have any record of | | Last session you were hitting |
| previous conversations. Could | | 429s on batch embedding jobs. |
| you describe the issue? | | There is an undocumented per-IP |
| | | burst limit (~500 req/10s). |
| | | Did the tenacity backoff fix |
| | | resolve it? |
+----------------------------------+ +----------------------------------+
Same model. Same prompt structure. Only the memory layer differs.
python -m scripts.compare inspect --user dev_alicePrints every compiled memory fact for a user. This is useful for understanding what context will be injected before a conversation starts.
3 memories compiled. profile_fact: 1 procedure: 1 episode_summary: 1
+--------------------------------------------------------------------------+
| [profile_fact] confidence=1.00 source=ep_001 tags=['role', 'python'] |
| |
| Indie developer building a RAG-based code assistant. Python only. |
+--------------------------------------------------------------------------+
+--------------------------------------------------------------------------+
| [procedure] confidence=0.94 source=ep_002 tags=['embeddings', '429'] |
| |
| Intermittent 429 errors on batch embed jobs. Likely undocumented |
| per-IP burst limit. Workaround: tenacity retry with exponential backoff. |
+--------------------------------------------------------------------------+
confidence is Statewave's confidence in the memory fact. source is the episode ID that generated it. When the token budget is tight, higher-confidence facts are always injected first.
python -m scripts.compare budget --user dev_bobCalls Statewave three times with different token budgets and shows how many memories fit at each limit.
+------------+----------------+-------------------+--------------------------+
| max_tokens | token_estimate | memories_returned | context preview |
+------------+----------------+-------------------+--------------------------+
| 200 | 178 | 2 | Dev building Slack bot |
| 500 | 443 | 4 | Dev building Slack bot |
| 800 | 761 | 6 | Dev building Slack bot |
+------------+----------------+-------------------+--------------------------+
dev_bob has 6 sessions ~2,800 tokens of raw history
Statewave at max_tokens=800 ~761 tokens of ranked signal
This demonstrates the core value proposition: raw history grows linearly with no ranking. Statewave always fits within your budget and always returns the highest-value facts first.
python -m scripts.compare chat --user dev_aliceOpens an interactive terminal chat session backed by Statewave. Each response shows how many memories were used and the token estimate. Each turn is recorded as a new episode, so memory accumulates as you chat.
The application exposes four HTTP endpoints.
Send a user message and receive a memory-backed assistant response.
Request body
{
"user_id": "dev_alice",
"message": "How do I handle 429s in batch jobs?"
}| Field | Type | Required | Description |
|---|---|---|---|
user_id |
string | Yes | The subject ID. Any string. Created automatically on first use. |
message |
string | Yes | The user's message. |
Response body
{
"user_id": "dev_alice",
"message": "How do I handle 429s in batch jobs?",
"response": "You hit this last session. Use tenacity with exponential backoff...",
"episode_id": "ep_0012",
"context_used": true,
"token_estimate": 283
}| Field | Type | Description |
|---|---|---|
response |
string | The assistant's reply. |
episode_id |
string | The Statewave episode ID for this turn. |
context_used |
boolean | true if at least one memory fact was injected. |
token_estimate |
integer | Tokens consumed by the assembled memory context. |
Returns all compiled memory facts for a user. Useful for debugging what the assistant knows before a conversation starts.
curl http://localhost:8000/api/v1/memory/dev_aliceReturns { "status": "ok" }. Used by the UI health indicator in the top bar.
statewave-personal-assistant/
|
|-- app/ Main application package
| |-- api/
| | +-- routes.py Three endpoints: POST /chat, GET /memory/{user_id}, POST /compare
| |-- core/
| | +-- config.py All settings loaded from .env via Pydantic
| |-- data/
| | +-- profiles.py dev_alice and dev_bob episode histories (seed data)
| |-- models/
| | |-- chat.py ChatRequest and ChatResponse Pydantic schemas
| | +-- memory.py ContextBundle, MemoryState Pydantic schemas
| |-- services/
| | |-- llm.py Async OpenAI wrapper; injects assembled_context
| | +-- statewave.py Statewave HTTP client (record, compile, context, search)
| |-- static/
| | +-- index.html Single-page chat UI (no build step required)
| +-- main.py FastAPI app; mounts static files; defines GET /
|
|-- scripts/
| |-- seed.py Reads profiles.py, writes episodes, compiles memories
| +-- compare.py CLI commands: compare / inspect / budget / chat
|
|-- tests/ 24 tests; all HTTP calls are mocked
| |-- conftest.py Shared fixtures (mock Statewave, mock OpenAI)
| |-- test_routes.py Route-level integration tests
| |-- test_statewave_client.py Unit tests for the Statewave client
| +-- test_models.py Pydantic model validation tests
|
|-- .env.example Copy this to .env and fill in your keys
|-- pyproject.toml Dependencies and project metadata
+-- README.md
If you want to understand the memory pattern and adapt it to your own project, read these three files in order:
app/services/statewave.py-- The Statewave client. Four methods:record_episode,compile_memories,get_context,list_memories. This is the complete memory integration surface.app/api/routes.py-- The chat route. Shows exactly whereget_contextis called (before the LLM) and whererecord_episodeis called (after).app/services/llm.py-- The LLM wrapper. Shows howassembled_contextis inserted into the system prompt.
Replace app/services/llm.py with any OpenAI-compatible provider (Azure OpenAI, Anthropic via a compatibility layer, local Ollama). The Statewave layer is completely provider-agnostic. assembled_context is a plain string that goes into any system prompt.
Call POST /api/v1/chat with any user_id string. Statewave creates the subject on the first episode write. No registration or setup step is needed. After a few turns, call POST /v1/memories/compile (via the Statewave API directly, or through your own scheduled job) to compile memories from their episodes.
Edit app/data/profiles.py and re-run python -m scripts.seed. Each profile is a list of { user_message, assistant_response } pairs. Statewave extracts structured memory facts from each pair automatically during compilation.
In production, you would not compile memories after every single episode. Instead, run compilation on a schedule: after every N episodes, or on a daily cron job. The Statewave API supports async compilation via POST /v1/memories/compile with "async": true, which returns a job_id you can poll.
Statewave runs on any PostgreSQL instance with the pgvector extension enabled. Point STATEWAVE_BASE_URL at your deployed instance. If you enable Statewave's optional API key authentication, set STATEWAVE_API_KEY in your environment. The client in app/services/statewave.py already handles the X-Api-Key header.
The entire memory layer is two calls: _get_context before the LLM/agent call, _record_episode after. This is already implemented, framework-agnostic, and copy-paste ready in statewave_integration.py — the snippets below adapt its memory_chat pattern to two other stacks. In both cases, the Statewave calls themselves do not change; only how you call the model does.
The Anthropic Messages API takes system as a top-level parameter, not a {"role": "system"} entry in the messages list — that is the one real difference from the OpenAI-shaped code in statewave_integration.py.
from anthropic import AsyncAnthropic
from statewave_integration import _get_context, _record_episode
_client = AsyncAnthropic() # reads ANTHROPIC_API_KEY from the environment
async def memory_chat_claude(user_id: str, message: str) -> str:
# 1. Same as the OpenAI pattern — fetch ranked, token-bounded memory.
context = await _get_context(user_id, message)
system = "You are a helpful assistant."
if context.strip():
system += f"\n\n## What you know about this user\n{context}"
# 2. Framework-specific part: Claude wants `system` outside `messages`.
response = await _client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
system=system,
messages=[{"role": "user", "content": message}],
)
reply = response.content[0].text
# 3. Same as the OpenAI pattern — record the turn.
await _record_episode(user_id, message, reply)
return replyIdentical to the OpenAI case: both _get_context and _record_episode calls. Framework-specific: system is a top-level messages.create() argument, and the reply is read from response.content[0].text instead of completion.choices[0].message.content.
CrewAI has no single "system prompt" injection point the way a raw chat completion call does — a crew is built from Agents with a backstory and Tasks with a description, and you call crew.kickoff() once. The natural integration point is injecting assembled_context into the agent's backstory at construction time, and recording the episode from the final crew output.
from crewai import Agent, Crew, Task
from statewave_integration import _get_context, _record_episode
async def memory_chat_crewai(user_id: str, message: str) -> str:
# 1. Same as the OpenAI pattern — fetch ranked, token-bounded memory.
context = await _get_context(user_id, message)
backstory = "You are a helpful assistant."
if context.strip():
backstory += f"\n\n## What you know about this user\n{context}"
# 2. Framework-specific part: memory goes into the agent's backstory,
# not a chat message — CrewAI has no per-call system prompt.
assistant = Agent(role="Assistant", goal="Help the user", backstory=backstory)
task = Task(description=message, agent=assistant, expected_output="A helpful reply")
crew = Crew(agents=[assistant], tasks=[task])
result = crew.kickoff()
reply = str(result)
# 3. Same as the OpenAI pattern — record the turn.
await _record_episode(user_id, message, reply)
return replyIdentical to the OpenAI case: both _get_context and _record_episode calls. Framework-specific: memory is injected via Agent(backstory=...) at construction time rather than a per-request system message, and crew.kickoff() is synchronous — wrap it in asyncio.to_thread if you need it off the event loop in a real async app.
No API keys or running servers are required to run the test suite. All HTTP calls to both the LLM and Statewave are mocked.
# Run all 24 tests
pytest -v
# Run tests for a specific file
pytest tests/test_routes.py -v
# Run a single test by name
pytest tests/test_routes.py::test_chat_endpoint -v
# Lint the codebase
ruff check .
# Type check
mypy app scriptsCopy .env.example to .env and configure the variables below. Only LLM_API_KEY is required to get started.
| Variable | Required | Default | Description |
|---|---|---|---|
LLM_API_KEY |
Yes | API key for your LLM provider. Works with any OpenAI-compatible API. | |
OPENAI_MODEL |
No | gpt-4o-mini |
Model identifier passed to the LLM API. |
STATEWAVE_BASE_URL |
No | http://localhost:8100 |
Base URL of the Statewave server. Change this for production deployments. |
STATEWAVE_MAX_TOKENS |
No | 800 |
Maximum tokens Statewave may use for an assembled context bundle. Lower values = fewer facts injected but less LLM cost per request. |
STATEWAVE_API_KEY |
No | API key for Statewave, if you have enabled authentication on your Statewave instance. Leave empty for local development. | |
HOST |
No | 0.0.0.0 |
Address the application server binds to. |
PORT |
No | 8000 |
Port the application server listens on. |
LOG_LEVEL |
No | info |
Logging verbosity. One of debug, info, warning, error, critical. |
CORS_ORIGINS |
No | http://localhost:8000,... |
Comma-separated list of allowed CORS origins. Set this for production deployments. |
get_context is awaited directly in the request path (app/api/routes.py, in both chat() and compare()) — it blocks the LLM call from starting. Every memory-backed request pays one extra network round-trip compared to a no-memory baseline. The actual added latency depends entirely on your Statewave deployment (co-located with your app vs. cross-region, cold vs. warm connection pool), so don't take a number from anyone's README — measure it in your own environment. Every response already carries an X-Request-ID header (set by request_id_middleware in app/main.py), which you can correlate with your own timing/logging to see the real number for your setup.
Episodes are appended, not merged, so concurrent record_episode calls for the same subject_id are safe — there's no read-modify-write race on the episode log itself. Memory compilation (compile_memories / compile_memories_wait) is different: it processes a subject's episode backlog into compiled memories, and in this reference app it's only triggered explicitly (from scripts/seed.py), not on every request. If your app triggers compilation from request handlers, simultaneous compilation calls for the same subject need external coordination (a lock, or a single scheduled job) — this is exactly why the "Run memory compilation on a schedule" guidance above recommends a periodic job rather than compiling inline.
| Symptom | Cause | Fix |
|---|---|---|
curl: (7) Failed to connect to localhost:8100, or app logs "Cannot connect to Statewave" |
Statewave server isn't running | Start it — npx @statewavedev/statewave (see 5-Minute Path) or docker compose up -d in the cloned statewave repo. |
uvicorn crashes on startup with TypeError: Router.__init__() got an unexpected keyword argument 'on_startup' (or a similar FastAPI/Starlette internals error) |
Your shell's venv isn't activated, so uvicorn/python resolve to a global Python install with a mismatched FastAPI/Starlette pair instead of this project's pinned versions |
Activate the venv first — .venv\Scripts\Activate.ps1 (Windows PowerShell) or source .venv/bin/activate (macOS/Linux/Git Bash) — or invoke it explicitly: .venv\Scripts\python.exe -m uvicorn app.main:app --reload. |
GET /api/v1/memory/{user_id} returns entries: [] even after chatting |
Episodes were recorded but never compiled — memory compilation is a separate, explicit step from episode recording | Re-run python -m scripts.seed, or trigger compile_memories for that subject yourself. |
| The assistant's response doesn't reflect something the user just said | Same root cause as above: get_context only returns already-compiled memories, not raw uncompiled episodes |
Compile memories after enough new episodes accumulate (see "Episode" vs. "Memory" in Core Concepts) rather than expecting every episode to be immediately queryable. |
Do I need a Statewave account? No. Statewave is self-hosted and open source (Apache 2.0). You run it yourself with Docker. There is no SaaS plan, no sign-up, and no vendor API key.
Does this only work with OpenAI?
No. The LLM service in app/services/llm.py uses the OpenAI Python SDK, but any provider that is compatible with the OpenAI API format will work. The Statewave layer is completely decoupled from the LLM provider.
What happens if Statewave is down?
The record_episode call at the end of each request is fire-and-forget: if it fails, the chat response is still returned to the user. The get_context call at the start of the request will return an empty context on failure, so the assistant responds without memory rather than returning an error.
How do memories get updated over time?
New episodes are recorded after every conversation turn. Memories are compiled from episodes when you call POST /v1/memories/compile. In this demo, compilation happens during the seed step. In production, you would run compilation periodically, for example after every 5 new episodes or on a nightly cron job.
Can I see exactly what context was injected for a given response?
Yes. Each ChatResponse includes episode_id and token_estimate. You can call GET /api/v1/memory/{user_id} to see all compiled memories, and the right panel of the web UI shows the same information live after each response.
MIT. See LICENSE.
