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