![[Update] Storage interface has been added to Strands Agents](https://images.ctfassets.net/ct0aopd36mqt/4o8n2qvRpfnsx0yGmKgDZr/1b05f3211bdf64deb5322bf5be42b202/StrandsAgents.png?w=3840&fm=webp)
[Update] Storage interface has been added to Strands Agents
This page has been translated by machine translation. View original
Introduction
This is Kamino from the Consulting Department.
While browsing the Strands Agents documentation, I noticed a "New" badge on the Storage page.
It's a common interface for storing agent-related data, and plugins such as Session Management, Context Offloader, and Memory use this Storage under the hood. Developers only need to choose a backend once.
Seeing this reminded me that I had touched a storage with the same name when I was testing the Context Offloader previously.
Back then, it was a storage that the Context Offloader plugin owned internally.
It seems that it has now been extracted as an SDK-wide common interface!
In this article, I'll check what has changed with this update and actually run InMemoryStorage and S3Storage to see what gets stored where.
Prerequisites
The verification environment is as follows.
| Item | Version / Setting |
|---|---|
| Python | 3.13.11 |
| strands-agents | 1.48.0 |
| Model | Claude Haiku 4.5 on Amazon Bedrock |
| Region | us-east-1 |
We proceed assuming you have a state where you can call models on Bedrock, and AWS credentials to create S3 buckets.
uv init --python 3.13
uv add strands-agents boto3
The mechanism of Context Offloader itself was covered in the previous article, so I'll skip it here. To put it briefly, it's a plugin that, when a tool result exceeds a threshold, offloads it to external storage before putting it in the context, leaving only a preview and a reference key in the conversation history.
What Changed with the Common Storage
Let me first check the differences I was curious about.
What was used in the previous article was the FileStorage from the Context Offloader package. This still remains in the same place.
from strands.vended_plugins.context_offloader import FileStorage
What appears in this documentation is the common Storage directly under the SDK. Three backends are provided: InMemoryStorage, LocalFileStorage, and S3Storage.
from strands.storage import InMemoryStorage, LocalFileStorage, S3Storage
Confusingly, both packages have classes with the same names InMemoryStorage and S3Storage.
The difference is in the interface: the former has store and retrieve, while the latter has write / read / delete / list. The plugin side distinguishes the two by the presence of methods, and in the implementation the older storage was called _LegacyStorage. It's the one kept for compatibility.
Here's a table comparing the interfaces:
| Previous FileStorage | Common Storage | |
|---|---|---|
| Import source | vended_plugins.context_offloader | strands.storage |
| Methods | store / retrieve | write / read / delete / list |
| Data handled | bytes + Content-Type | bytes only |
| Compatible plugins | Context Offloader only | Also shared with Session Management and Memory |
Comparing What Gets Stored
Just reading the explanation isn't very intuitive, so let me run the same agent with only the storage swapped out in both cases and compare the resulting files. The tool used returns a JSON of 1,000 users, and the contents are shown in the next section.
70 bytes ./artifacts-legacy/.metadata.json
189270 bytes ./artifacts-legacy/1785117571419_1_tooluse_uAzZomKfAU1SlfKN6CTgfD_0.txt
189282 bytes ./artifacts-unified/offloader/tooluse_b209HdLjoMKbt9KAtNZA2J_0
Oh, there are some differences.
The previous FileStorage had a separate management file called .metadata.json that recorded the Content-Type. The filename included a timestamp and cycle number, along with an extension based on the Content-Type.
In contrast, the common LocalFileStorage has only a single file under the offloader/ directory, named directly after the tool execution ID. There is no management file. Since the common Storage is an interface that only stores bytes, there's no place to record the Content-Type — instead, the plugin now embeds it at the beginning of the data. The file size increasing from 189,270 bytes to 189,282 bytes — exactly 12 bytes — appears to be due to this embedded header.
I understood this as a design that keeps the storage layer thin and has the plugin carry the necessary information itself — a structure where the Storage's responsibilities are narrowed so it can be used by multiple plugins!
The 4 Methods and namespace
Let me also confirm how to use the common Storage.
It handles bytes, and keys are slash-delimited strings. list does prefix search, and read on a non-existent key returns None rather than raising an exception.
storage = InMemoryStorage()
await storage.write("users/alice.json", b'{"name": "Alice"}')
await storage.read("users/alice.json") # b'{"name": "Alice"}'
await storage.read("users/carol.json") # None
await storage.list("users/") # ['users/alice.json']
await storage.delete("users/alice.json") # No error even for non-existent keys
# namespace: creates a view with a prefix prepended to keys
ns = storage.namespace("tenant-a/")
await ns.write("config.json", b"{}") # Actual key is tenant-a/config.json
In Python, Storage doesn't need to inherit from a specific class — any class that implements these 4 methods as async can be passed as a custom backend. In TypeScript, it's defined as an interface, so implementing the same 4 methods is sufficient.
Running with InMemoryStorage
From here, let's try passing it to Context Offloader and using it.
The tool used for verification returns JSON for 1,000 users. Only 2 of them have the admin role, placed toward the end at IDs 742 and 987. Since they won't fit within the preview range, the agent is forced to retrieve the offloaded data.
"""A tool that intentionally returns large results for verification purposes."""
import json
from strands import tool
ROLES = ["user", "viewer", "editor", "user", "viewer"]
@tool
def list_users() -> str:
"""Retrieves the user list from the internal system."""
users = [
{
"id": i,
"name": f"user{i:03d}",
"email": f"user{i:03d}@example.com",
"role": "admin" if i in (742, 987) else ROLES[i % len(ROLES)],
"department": f"dept-{i % 12}",
"note": "This user is a standard user of the internal system.",
}
for i in range(1, 1001)
]
return json.dumps({"users": users}, ensure_ascii=False)
On the agent side, you just create the Storage and pass it.
"""Offload large tool results using ContextOffloader + InMemoryStorage."""
import asyncio
from strands import Agent
from strands.models import BedrockModel
from strands.storage import InMemoryStorage
from strands.vended_plugins.context_offloader import ContextOffloader
from tools import list_users
async def main() -> None:
storage = InMemoryStorage()
agent = Agent(
model=BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0"),
tools=[list_users],
plugins=[ContextOffloader(storage=storage)],
)
result = await agent.invoke_async("Please list all users with the admin role from the user list.")
print("Final answer:", result)
for key in await storage.list():
data = await storage.read(key)
print(f" {key}: {len(data):,} bytes")
if __name__ == "__main__":
asyncio.run(main())
uv run 02_offloader_inmemory.py
Tool #1: list_users
Tool #2: retrieve_offloaded_content
Tool #3: retrieve_offloaded_content
Final answer: Looking at the full list, there are **2 users** with the admin role:
| ID | Name | Email | Department |
|-----|------|--------------|------|
| 742 | user742 | user742@example.com | dept-10 |
| 987 | user987 | user987@example.com | dept-3 |
offloader/tooluse_uEtG8LApvokM29jkIojFsa_0: 189,282 bytes
The large tool result was offloaded, the agent used retrieve_offloaded_content to re-read the offloaded data, and it was able to answer with the 2 admin users. We can see that 189,282 bytes are stored in storage.
Switching to S3Storage
Let's create a bucket for verification.
export OFFLOAD_BUCKET=strands-offload-demo-$(aws sts get-caller-identity --query Account --output text)
aws s3api create-bucket --bucket "$OFFLOAD_BUCKET" --region us-east-1
aws s3api put-public-access-block --bucket "$OFFLOAD_BUCKET" \
--public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
The IAM permissions required at runtime for S3Storage are four: s3:PutObject, s3:GetObject, s3:DeleteObject, and s3:ListBucket (bucket creation and deletion require additional permissions).
The only code change is swapping InMemoryStorage for S3Storage.
- from strands.storage import InMemoryStorage
+ from strands.storage import S3Storage
- storage = InMemoryStorage()
+ storage = S3Storage(BUCKET, prefix="tool-results", region_name="us-east-1")
agent = Agent(
model=BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0"),
tools=[list_users],
- plugins=[ContextOffloader(storage=storage)],
+ plugins=[ContextOffloader(storage=storage, evict_after_cycles=None)],
)
The reason you can just swap the backend creation part is precisely because everything is aligned under the common interface of write / read / delete / list. Since both InMemoryStorage and S3Storage have the same 4 methods, the plugin side can handle them without knowing the specific implementation.
evict_after_cycles=None was added because I wanted to later verify the contents in S3. By default, offloaded data older than 20 cycles is automatically deleted.
Let's run it and check the S3 side.
uv run 03_offloader_s3.py
aws s3 ls "s3://$OFFLOAD_BUCKET/" --recursive --human-readable
2026-07-27 08:27:43 184.8 KiB tool-results/offloader/tooluse_84QjVEktPe9TtPT56xnGWu_0
It's properly stored as an object. Under the specified prefix, the plugin-added offloader/ follows, and after that comes the tool execution ID and content block number. The key structure is exactly the same as with local files.
Reading Back from a Separate Process
Since it's stored in S3, it can be accessed from a separate process as well.
Let me try reading the offloaded data from a different script.
"""Read back offloaded data remaining in S3 from a separate process that ran the agent."""
import asyncio
import json
import os
from strands.storage import S3Storage
BUCKET = os.environ["OFFLOAD_BUCKET"]
def unframe(frame: bytes) -> tuple[bytes, str]:
"""Strip the 2-byte length + Content-Type header from the beginning and extract the body."""
ct_len = int.from_bytes(frame[:2], "big")
content_type = frame[2 : 2 + ct_len].decode("utf-8")
return frame[2 + ct_len :], content_type
async def main() -> None:
storage = S3Storage(BUCKET, prefix="tool-results", region_name="us-east-1")
for key in await storage.list():
frame = await storage.read(key)
body, content_type = unframe(frame)
users = json.loads(body)["users"]
print(f"{key}")
print(f" content-type : {content_type}")
print(f" users : {len(users)}")
print(f" admins : {[u['name'] for u in users if u['role'] == 'admin']}")
if __name__ == "__main__":
asyncio.run(main())
Just recreate S3Storage with the same bucket and prefix, then call list and read. unframe is processing that strips the 12-byte frame seen in the previous section, and it depends on the internal storage format of Context Offloader. Since the storage format is an internal implementation detail, please do not depend on it directly from your application. From the agent, you can retrieve offloaded data using the retrieve_offloaded_content tool.
uv run 04_read_from_s3.py
offloader/tooluse_84QjVEktPe9TtPT56xnGWu_0
content-type : text/plain
users : 1000
admins : ['user742', 'user987']
The full 1,000 offloaded records were readable!
Cleanup
Let's delete the verification bucket.
aws s3 rm "s3://$OFFLOAD_BUCKET" --recursive
aws s3api delete-bucket --bucket "$OFFLOAD_BUCKET"
Which Plugins Can Use the Common Storage
The Storage page in the official documentation mentions Context Offloader, Session Management, and Memory, but upon actually checking the code, the situation differs between Python and TypeScript.
| Plugin | Python (1.48.0) | TypeScript |
|---|---|---|
| Context Offloader | Can accept common Storage | Can accept common Storage |
| Session Management | Dedicated classes (FileSessionManager / S3SessionManager) | Can accept common Storage |
| Memory | Custom interface (MemoryStore) | Custom interface (MemoryStore) |
In Python, only Context Offloader can currently accept the common Storage. Session Management uses dedicated classes called FileSessionManager / S3SessionManager. To be honest, if you're developing purely in Python, the benefits of the common Storage may be limited at this point. Since the TypeScript version already allows SessionManager to accept common Storage, the Python side might catch up eventually.
Memory, in both Python and TypeScript, operates through its own interface of search / add / add_messages. Since the nature of the data it handles (vector search, memory with metadata) doesn't quite fit with write / read / delete / list, I'm not sure whether this will be integrated into the common Storage... It's something I'm curious about...
Conclusion
The storage that was previously plugin-specific has been extracted into a common Storage!
In Python it's currently only Context Offloader, but in TypeScript, Session Management already supports it as well.
Since Storage doesn't require inheriting from a specific class — as long as the 4 methods are in place you can use it as a custom backend — it might be interesting to build one with DynamoDB.
I hope this article is at least somewhat helpful.
Thank you for reading to the end!
