Skip to main content

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.

Scope

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):

MetricWithout scratchpadWith scratchpad
Tokens per turn6K–28K (growing)~7K (flat)
Turns to completeRuns out of context16 turns
Tool result re-readsEvery turnZero

The tiers at a glance​

TierBackendLifecyclePurpose
workingRedisPer-run, expires after 1hScratchpad for in-flight tool results, captured automatically
sessionPostgres24h defaultConversation/plan state within a user session
episodicPostgresTTL-basedCross-run state: conversation history, cached results
semanticpgvectorIndefiniteLong-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:

  1. The workflow calls ctx.domain("market_data", "fetch_prices", tickers=["AAPL"], days=90)
  2. The callback returns 15KB of OHLC data
  3. The runtime stores it in the scratchpad as market_data_fetch_prices.1, along with a generated one-line summary of its shape
  4. The next call passes the handle instead of the payload:
    await ctx.domain(
    "analytics", "calculate_performance",
    prices="$ref:market_data_fetch_prices.1",
    )
  5. The runtime resolves the handle to the full data before dispatching, so analytics.calculate_performance receives 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:

DimensionSourceUse case
tenant_idAlways (automatic)Outermost boundary
agentAgent namePer-agent within tenant
agent_versionAgent versionPer-version state
user_idSub-user identityPer end-user
user_groupSub-user identityPer user group
session_idConversation sessionPer conversation
workflowWorkflow namePer workflow within agent
run_idExecution runPer execution (ephemeral)
channel_idSlack/chatPer chat channel
thread_tsSlackPer 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​

NeedTierWhy
Pass a large result between steps without putting it in the promptworkingCaptured on every domain call; refs resolve before dispatch
Remember the last 100 messages per userepisodic (conversation_memory())Postgres-backed, TTL-eviction
Cache an expensive calculation per (advisor, client) for 24hepisodicMulti-dimensional scope, TTL
Conversation/plan state within a session that spans multiple workflow runssessionFirst-class tier with 24h default TTL
Learn facts about a client over time and recall by similaritysemantic (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_id is implicit. Don't include it in your scope=[...] list -- the runtime always prepends it. Listing it explicitly works but is redundant.
  • Working tier ignores type. Scratchpad entries are always ScratchpadEntry dataclass 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.
  • $ref resolution is recursive. Pass $ref:foo.1 as 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. InMemoryWorkingMemory mimics Redis for tests; the Redis backend takes over in production via the infra setup.py ready hook.

Reference​