Working Memory
The Waxell runtime ships with a multi-tier memory primitive that addresses the context-window explosion problem in agentic loops. Domain-call results capture into a fast scratchpad under a reference handle, and handles resolve back to full data on the runtime side before a call is dispatched -- so the bytes move tool-to-tool without passing through the LLM's conversation.
This page covers the native runtime — agents you build with @agent,
@workflow, and ctx.domain. Adapter-backed agents (claude_agent_sdk,
pydantic_ai, LangGraph, CrewAI) manage context through their own framework;
talk to us if you want Waxell-managed memory on one of those.
The problem it solves
A typical agentic loop has the LLM call a tool, see the full result, then decide the next action. After 10 turns of fetching prices, dividends, research notes, and benchmarks, the conversation history is 60KB+ of JSON the LLM has to re-read every turn. You either:
- Run out of context window
- Burn tokens re-reading data you already have
- Have the LLM hallucinate data it already fetched but can't see anymore
- Truncate history and lose information
Working memory inverts this. Domain results go to a scratchpad keyed by
tool_name.call_number, and a call's inputs can carry a handle
($ref:market_data_fetch_prices.1) instead of the data itself. The runtime
resolves the handle before dispatching, so the actual bytes flow tool-to-tool
without entering the conversation.
Your workflow code passes the handles. The name is derived from the call you just made, so you always know it — see how refs are named.
Measured impact (finance portfolio_analyst, 6-phase pipeline):
| Metric | Without scratchpad | With scratchpad |
|---|---|---|
| Tokens per turn | 6K–28K (growing) | ~7K (flat) |
| Turns to complete | Runs out of context | 16 turns |
| Tool result re-reads | Every turn | Zero |
The tiers at a glance
| Tier | Backend | Lifecycle | Purpose |
|---|---|---|---|
working | Redis | Per-run, expires after 1h | Scratchpad for in-flight tool results, captured automatically |
session | Postgres | 24h default | Conversation/plan state within a user session |
episodic | Postgres | TTL-based | Cross-run state: conversation history, cached results |
semantic | pgvector | Indefinite | Long-term facts with vector search |
All four tiers share the same MemorySpec API and the same tenant isolation guarantees. The tier= field selects the backend and lifecycle.
Python: declaring memory on an agent
Use the convenience constructors from waxell_sdk.core.specs.memory_spec:
from waxell_sdk import agent
from waxell_sdk.core.specs.memory_spec import (
scratchpad,
conversation_memory,
semantic_memory,
)
@agent(
name="portfolio_analyst",
memory={
# Tier 1: captures every domain call during a run
"scratchpad": scratchpad(),
# Tier 2: per-user conversation across runs
"conversation": conversation_memory(scope="user_id", ttl="30d"),
# Tier 3: per-client facts the agent has learned
"client_context": semantic_memory(
scope=["user_id", "portfolio_id"],
description="Facts about this advisor's relationship with this client",
),
},
...
)
class PortfolioAnalyst:
...
Or use MemorySpec directly when you need control:
from waxell_sdk.core.specs.memory_spec import MemorySpec
memory = {
"scratchpad": MemorySpec(
tier="working",
scope=["run_id"],
),
"cached_analysis": MemorySpec(
tier="episodic",
scope=["user_id", "portfolio_id"],
type="dict",
ttl="24h",
),
}
YAML: declaring memory in waxell.yaml
memory:
scratchpad:
tier: working
scope: [run_id]
conversation:
tier: episodic
scope: user_id
type: list
ttl: 30d
max_items: 100
client_knowledge:
tier: semantic
scope: [user_id, portfolio_id]
searchable: true
description: Facts about this client relationship
For all available fields and validation rules, see the waxell.yaml memory reference.
How working memory captures domain results
Capture happens on ctx.domain(...) calls. Every successful one is stored:
- The workflow calls
ctx.domain("market_data", "fetch_prices", tickers=["AAPL"], days=90) - The callback returns 15KB of OHLC data
- The runtime stores it in the scratchpad as
market_data_fetch_prices.1, along with a generated one-line summary of its shape - The next call passes the handle instead of the payload:
await ctx.domain(
"analytics", "calculate_performance",
prices="$ref:market_data_fetch_prices.1",
) - The runtime resolves the handle to the full data before dispatching, so
analytics.calculate_performancereceives the real OHLC dict
How a ref is named
{tool_name}.{call_number}, with . and : in the tool name replaced by _.
So the first call to market_data.fetch_prices is
$ref:market_data_fetch_prices.1, and a second call in the same run is .2 —
the number counts calls to that tool, not conversation turns.
An unresolvable handle passes through as a literal string rather than raising,
so check the name if a tool receives "$ref:..." instead of data.
Input signals are seeded too. Before the loop starts, each dict/list signal
is captured as input_<name>.1, so a workflow can hand its own inputs to a
domain call by reference rather than re-serialising them.
Repeated calls are served from the scratchpad. When a domain action is marked
idempotent, the runtime looks the call up by (action, args) before dispatching
and returns the captured result if it finds one. This is what makes a resumed run
cheap: the work already done is not redone.
Tenant isolation
Two layers of isolation, either of which catches a bug in the other:
Physical layer. The Redis backend prefixes every key with tenant:{tenant_id}:. The _tenant_context() context manager extracts tenant_id from the scope key and sets the ContextVar around each Redis operation -- so isolation holds even when the Celery worker has no upstream tenant context set.
Logical layer. The scope key itself always starts with tenant_id, enforced by MemoryScopeResolver regardless of what dimensions the developer declares in scope=[...].
Result: a memory write under tenant A cannot be read by tenant B even if scope keys collide, even if a worker has stale context, even if the dev forgot to include tenant_id in the scope list. tenant_id is always prepended automatically.
Scope dimensions
Multi-dimensional scoping composes on top of tenant_id:
| Dimension | Source | Use case |
|---|---|---|
tenant_id | Always (automatic) | Outermost boundary |
agent | Agent name | Per-agent within tenant |
agent_version | Agent version | Per-version state |
user_id | Sub-user identity | Per end-user |
user_group | Sub-user identity | Per user group |
session_id | Conversation session | Per conversation |
workflow | Workflow name | Per workflow within agent |
run_id | Execution run | Per execution (ephemeral) |
channel_id | Slack/chat | Per chat channel |
thread_ts | Slack | Per thread |
Combine them as needed:
# Per-(advisor, client) cached analysis
MemorySpec(
tier="episodic",
scope=["user_id", "portfolio_id"],
type="dict",
ttl="24h",
)
When to use which tier
| Need | Tier | Why |
|---|---|---|
| Pass a large result between steps without putting it in the prompt | working | Captured on every domain call; refs resolve before dispatch |
| Remember the last 100 messages per user | episodic (conversation_memory()) | Postgres-backed, TTL-eviction |
| Cache an expensive calculation per (advisor, client) for 24h | episodic | Multi-dimensional scope, TTL |
| Conversation/plan state within a session that spans multiple workflow runs | session | First-class tier with 24h default TTL |
| Learn facts about a client over time and recall by similarity | semantic (semantic_memory()) | Vector search, indefinite retention |
Typed memory schemas
Memory values are stored and returned as plain dicts. When a slot holds something structured, declare the shape with Pydantic and validate on read:
from pydantic import BaseModel
class ClientNote(BaseModel):
summary: str
risk_flags: list[str]
note = ClientNote.model_validate(await ctx.memory.get("client_note"))
Validating at the boundary means a shape change surfaces where you can handle
it, rather than as a KeyError three calls later.
Common gotchas
tenant_idis implicit. Don't include it in yourscope=[...]list -- the runtime always prepends it. Listing it explicitly works but is redundant.- Working tier ignores
type. Scratchpad entries are alwaysScratchpadEntrydataclass values;type="dict"/type="list"only applies to episodic/session tiers. - One scratchpad per run, always on. Every run gets one; there's nothing to enable.
$refresolution is recursive. Pass$ref:foo.1as a value, a nested dict field, or inside a list -- the runtime walks the structure and resolves all refs.- Local dev uses in-memory backends.
InMemoryWorkingMemorymimics Redis for tests; the Redis backend takes over in production via the infrasetup.pyready hook.
Reference
- waxell.yaml memory field reference -- field-by-field YAML schema
- Execution Context -- how
ctx.scratchpadis plumbed through agent runs - Workflow Envelope -- run lifecycle that drives scratchpad TTL