[Update] Storage interface has been added to Strands Agents

[Update] Storage interface has been added to Strands Agents

I tried out the newly added shared Storage in Strands Agents!
2026.07.28

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.

https://strandsagents.com/docs/user-guide/concepts/storage/

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.

https://dev.classmethod.jp/articles/strands-agents-context-offloader/

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.

Command
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.

Previous import
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.

Current import
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.

Result (previous FileStorage)
70 bytes      ./artifacts-legacy/.metadata.json
189270 bytes  ./artifacts-legacy/1785117571419_1_tooluse_uAzZomKfAU1SlfKN6CTgfD_0.txt
Result (common LocalFileStorage)
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.

Example of using it standalone
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.

tools.py
"""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.

02_offloader_inmemory.py
"""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())
Command
uv run 02_offloader_inmemory.py
Result (excerpt)
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.

Command
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.

03_offloader_s3.py
- 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.

Command
uv run 03_offloader_s3.py
aws s3 ls "s3://$OFFLOAD_BUCKET/" --recursive --human-readable
Result
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.

04_read_from_s3.py
"""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.

Command
uv run 04_read_from_s3.py
Result
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.

Command
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!

Share this article

AWSのお困り事はクラスメソッドへ