Guides

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

src/agent.ts
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

src/lib/kv.ts
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

src/agent.ts
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

  • get handlers — Return the entity or null if not found. Do not throw for missing records.
  • set handlers — Upsert (insert or replace). No return value.
  • update handlers — Merge updates into the existing record and return the updated entity. Throw if the record doesn't exist.
  • listBySession handlers — Return all records for the given session ID. Return an empty array if none exist.

Next Steps