Custom Storage Backends
Use your own database (Postgres, Redis, KV, DynamoDB) as agent storage. Implement handlers inline in the storage function.
By default, the agent uses localStorage() — filesystem-backed storage for development. For production, write a storage() function with your own handlers.
Why Custom Storage
- Existing infrastructure — Use your Postgres, Redis, KV, or DynamoDB instance
- Multi-tenant isolation — Scope data by tenant, project, or user in your schema
- Compliance — Keep agent data in your region or behind your firewall
- Observability — Integrate with your logging, metrics, and backup systems
See Storage for the design philosophy — storage holds only what the agent needs; application metadata like userId and title live in your app's database; the session ID is the join key.
Writing a Storage Function
The storage option on the agent is a function that receives a store object and dispatches it to your handlers via store.on():
import { agent } from "experimental-agent";
export const myAgent = agent("my-agent", {
model: "anthropic/claude-sonnet-4-6",
async storage(store) {
return await store.on({
"session.get": async ({ id }) => { /* → Session | null */ },
"session.set": async ({ id, value }) => { /* → void */ },
"session.update": async ({ id, updates }) => { /* → Session */ },
"message.get": async ({ id }) => { /* → Message | null */ },
"message.set": async ({ id, value }) => { /* → void */ },
"message.update": async ({ id, updates }) => { /* → Message */ },
"message.listBySession": async ({ sessionId }) => { /* → Message[] */ },
"part.get": async ({ id }) => { /* → Part | null */ },
"part.set": async ({ id, value }) => { /* → void */ },
"part.listBySession": async ({ sessionId }) => { /* → Part[] */ },
"sandbox.get": async ({ id }) => { /* → Sandbox | null */ },
"sandbox.set": async ({ id, value }) => { /* → void */ },
"sandbox.update": async ({ id, updates }) => { /* → Sandbox */ },
"setup.get": async ({ id }) => { /* → Setup | null */ },
"setup.set": async ({ id, value }) => { /* → void */ },
});
},
});To make this work with Vercel Workflow, add "use step" at the top of the function:
// @fragment object
async storage(store) {
"use step";
return await store.on({ /* same handlers */ });
},That's the only difference. The "use step" directive makes storage calls survive workflow replay.
Production Example: @vercel/kv2
The vade app uses @vercel/kv2 for production storage.
Define your KV stores
import { createKV } from "@vercel/kv2";
import type { Message, Part, Sandbox, Session, Setup } from "experimental-agent/storage";
const kv = createKV({ prefix: "myapp/" });
const store = kv.getStore("agent/");
export const sessions = store.getStore<Session>("session/");
export const messages = store.getStore<Message>("message/").withIndexes({
bySessionId: { key: (m) => m.sessionId },
});
export const parts = store.getStore<Part>("part/").withIndexes({
bySessionId: { key: (p) => p.sessionId },
});
export const sandboxes = store.getStore<Sandbox>("sandbox/");
export const setups = store.getStore<Setup>("setup/");Wire into the agent
import { agent } from "experimental-agent";
import type { Message, Part } from "experimental-agent/storage";
import { messages, parts, sandboxes, sessions, setups } from "./lib/kv";
export const myAgent = agent("my-agent", {
model: "anthropic/claude-sonnet-4-6",
async storage(store) {
"use step";
return await store.on({
"session.get": async ({ id }) => (await sessions.getValue(id)) ?? null,
"session.set": async ({ id, value }) => {
await sessions.set(id, value);
},
"session.update": async ({ id, updates }) => {
const existing = await sessions.getValue(id);
if (!existing) throw new Error(`Session not found: ${id}`);
const updated = { ...existing, ...updates };
await sessions.set(id, updated);
return updated;
},
"message.get": async ({ id }) => (await messages.getValue(id)) ?? null,
"message.set": async ({ id, value }) => {
await messages.set(id, value);
},
"message.update": async ({ id, updates }) => {
const existing = await messages.getValue(id);
if (!existing) throw new Error(`Message not found: ${id}`);
const updated = { ...existing, ...updates };
await messages.set(id, updated);
return updated;
},
"message.listBySession": async ({ sessionId }) => {
const result: Message[] = [];
for await (const [, entry] of messages.entries({ bySessionId: sessionId })) {
result.push(await entry.value);
}
return result;
},
"part.get": async ({ id }) => (await parts.getValue(id)) ?? null,
"part.set": async ({ id, value }) => {
await parts.set(id, value);
},
"part.listBySession": async ({ sessionId }) => {
const result: Part[] = [];
for await (const [, entry] of parts.entries({ bySessionId: sessionId })) {
result.push(await entry.value);
}
return result;
},
"sandbox.get": async ({ id }) => (await sandboxes.getValue(id)) ?? null,
"sandbox.set": async ({ id, value }) => {
await sandboxes.set(id, value);
},
"sandbox.update": async ({ id, updates }) => {
const existing = await sandboxes.getValue(id);
if (!existing) throw new Error(`Sandbox not found: ${id}`);
const updated = { ...existing, ...updates };
await sandboxes.set(id, updated);
return updated;
},
"setup.get": async ({ id }) => (await setups.getValue(id)) ?? null,
"setup.set": async ({ id, value }) => {
await setups.set(id, value);
},
});
},
});Handler Conventions
gethandlers — Return the entity ornullif not found. Do not throw for missing records.sethandlers — Upsert (insert or replace). No return value.updatehandlers — Mergeupdatesinto the existing record and return the updated entity. Throw if the record doesn't exist.listBySessionhandlers — Return all records for the given session ID. Return an empty array if none exist.
Next Steps
- Storage — Concepts and design philosophy
- Storage Reference — Full type reference
Extending Built-in Tools
Customize built-in tools without replacing them. Use tool.before, tool.after, needsApproval, and activeTools to add validation, transformation, and restrictions.
Sandbox Setup & Snapshots
One-time setup with version and run, snapshot-based fast resume, network policies, exposed ports, and lifecycle management.