Agent Apps Docs

Storage

Volume file storage, public URL sharing, and key-value storage with the @agentapps/sdk.

Storage

Agent Apps have access to three storage capabilities, each serving a different purpose:

CapabilityWhat it's forScope
ctx.volumeFiles (binary, text, images, any format)Per workspace
ctx.sharePublic URLs for volume filesPer workspace
ctx.storageStructured key-value data (JSON, config, state)Per installation

There is no separate "object storage" API. There is one file system — volume — and an optional sharing layer on top. Key-value storage is a separate system for structured data.

See the Storage & Volume Spec for full details.


Volume file storage (ctx.volume)

The volume is a filesystem scoped to the current workspace. It is mounted as /workspace inside containers, but when using the SDK, all paths are relative to /workspace — there are no absolute paths.

ctx.volume.read('docs/readme.md')     → reads /workspace/docs/readme.md
ctx.volume.write('output/chart.png')  → writes /workspace/output/chart.png
ctx.volume.list('src/')               → lists /workspace/src/

The volume is shared by all installations in the same workspace. All apps in a workspace see the same /workspace.

Methods

interface VolumeStorage {
  /** List entries in a directory. */
  list(path: string): Promise<DirEntry[]>;

  /** Read a file as raw bytes. Returns null if not found. */
  read(path: string): Promise<Uint8Array | null>;

  /** Write bytes to a file. Creates parent directories as needed. */
  write(path: string, data: Uint8Array): Promise<void>;

  /** Delete a file or directory. For directories, set recursive: true. */
  delete(path: string, options?: { recursive?: boolean }): Promise<void>;

  /** Get metadata for a file or directory. Returns null if not found. */
  stat(path: string): Promise<FileStat | null>;

  /** Get a temporary path scoped to the current session. */
  getTempPath(filename: string): string;
}

Types

interface DirEntry {
  name: string;
  type: 'file' | 'directory';
  size: number;
  modifiedAt: string;  // ISO 8601
}

interface FileStat {
  path: string;        // relative to /workspace
  type: 'file' | 'directory';
  size: number;
  createdAt: string;   // ISO 8601
  modifiedAt: string;  // ISO 8601
}

Example: write, read, and list

import { createApp } from '@agentapps/sdk';

const app = createApp({
  id: 'app.example.storage',
  version: '1.0.0',
  name: 'Storage Example',
  description: 'Demonstrates volume file storage',
  author: { name: 'Example', email: 'dev@example.com' },
  category: 'developer',
  runtime: { transport: 'stdio', command: 'node', args: ['./dist/index.js'] },
});

app.tool('save_report', {
  description: 'Save a JSON report to the volume and return its path',
  parameters: {
    type: 'object',
    properties: {
      filename: { type: 'string' },
      data: { type: 'object' },
    },
    required: ['filename', 'data'],
  },
  handler: async (args, ctx) => {
    const { filename, data } = args as { filename: string; data: Record<string, unknown> };

    // Write a file — parent directories are created automatically
    const path = `reports/${filename}`;
    const bytes = new TextEncoder().encode(JSON.stringify(data, null, 2));
    await ctx.volume.write(path, bytes);

    // Read it back
    const readBack = await ctx.volume.read(path);
    const content = readBack ? new TextDecoder().decode(readBack) : null;
    ctx.log('info', `Verified file contents: ${content?.length ?? 0} chars`);

    // List the directory
    const entries = await ctx.volume.list('reports');
    const fileList = entries.map((e) => `${e.name} (${e.type}, ${e.size} bytes)`);

    // Get metadata
    const stat = await ctx.volume.stat(path);

    return {
      path,
      size: stat?.size,
      entries: fileList,
    };
  },
});

Temporary paths

getTempPath(filename) returns a path scoped to the current session, like tmp/{sessionId}/filename. The directory is created automatically on first write. Use this for ephemeral files — intermediate builds, generated images, scratch data — instead of inventing your own paths.

// Get a temp path for a generated image
const tmpPath = ctx.volume.getTempPath('preview.png');

// Write to it — the tmp/{sessionId}/ directory is created on write
await ctx.volume.write(tmpPath, imageBytes);

// Share it (see Sharing layer below)
const { url } = await ctx.share.share(tmpPath, { expiresIn: 1800 });

Security

  • Path traversal protection: paths containing .. segments that escape /workspace are rejected. The resolved path must start with the volume root.
  • Workspace isolation: each workspace has its own volume directory. An app cannot access another workspace's files.
  • No absolute paths: all paths are relative to /workspace. The volume is the root — nothing exists outside it.

Direct mount (mountVolume)

Most apps access the volume through ctx.volume SDK callbacks. Apps that need real POSIX filesystem access (e.g., Bash running grep, find, pipes) can set mountVolume: true in their manifest to have the station bind-mount the volume directory directly into the container at /workspace. The app then uses Node's fs module natively — no callbacks.

See Define the manifest for details on when to use direct mount.


Sharing layer (ctx.share)

Sharing generates public URLs for files in the volume. It is not a separate storage system — the file lives in the volume, and sharing produces a URL that points to it.

Methods

interface ShareAccess {
  /**
   * Share a file from the volume. Returns a public URL.
   *
   * @param path     — path in the volume (relative to /workspace)
   * @param options  — mutability and expiry control
   */
  share(
    path: string,
    options?: {
      /** If true, the URL serves the latest version (live). Default: false (snapshot). */
      mutable?: boolean;
      /** URL expiry in seconds. Default: 3600 (1 hour). Max: 86400 (24 hours). */
      expiresIn?: number;
    },
  ): Promise<{
    url: string;
    expiresAt: string;  // ISO 8601
    mutable: boolean;
  }>;

  /** Revoke a shared URL. The file stays in the volume; only the URL stops working. */
  revoke(url: string): Promise<void>;
}

Snapshot vs Live

When you share a file, you choose between two mutability modes:

ModemutableBehavior
Snapshot (default)falseThe URL serves the content as it was when share() was called. If the file is later modified or deleted, the URL still serves the original until it expires or is revoked.
LivetrueThe URL always serves the latest version of the file. If the file is modified, the URL reflects the change. If the file is deleted, the URL returns 404.

Example: share and revoke

app.tool('share_screenshot', {
  description: 'Take a screenshot, save it to the volume, and return a public URL',
  parameters: {
    type: 'object',
    properties: {
      url: { type: 'string', description: 'URL to screenshot' },
    },
    required: ['url'],
  },
  handler: async (args, ctx) => {
    const { url } = args as { url: string };

    // Generate a screenshot (e.g., via Playwright, Replicate, etc.)
    const screenshotBytes = await captureScreenshot(url);

    // Save to a temp path on the volume
    const tmpPath = ctx.volume.getTempPath('screenshot.png');
    await ctx.volume.write(tmpPath, screenshotBytes);

    // Create a public URL — snapshot mode, expires in 1 hour
    const share = await ctx.share.share(tmpPath, {
      expiresIn: 3600,
    });

    return {
      url: share.url,
      expiresAt: share.expiresAt,
      mutable: share.mutable,
    };
  },
});

app.tool('revoke_share', {
  description: 'Revoke a previously shared URL',
  parameters: {
    type: 'object',
    properties: {
      url: { type: 'string', description: 'The shared URL to revoke' },
    },
    required: ['url'],
  },
  handler: async (args, ctx) => {
    const { url } = args as { url: string };
    await ctx.share.revoke(url);
    return { revoked: true };
  },
});

Use cases

  • Playwright screenshots — capture a screenshot, save it to the volume, share it so the user can view it in chat.
  • Replicate images — generate an image, write it to the volume, share the URL so the agent can embed it.
  • Generated reports — produce a PDF or HTML file, share it for download.
  • Live previews — use mutable: true to share a file that updates in real time (e.g., a build output that changes as the build progresses).

Events

When a shared URL is mutable and the underlying file changes, the station emits a file.updated event to subscribers. When a URL is revoked, a file.revoked event is emitted. This enables real-time updates — an agent displaying the file receives the event and refreshes.


Key-value storage (ctx.storage)

Key-value storage is a separate system from the volume. It is for structured data — JSON documents, configuration, app state — not files. All operations are scoped to the current installation: apps cannot read or write data belonging to other installations.

When to use ctx.storage vs ctx.volume

NeedUse
Store user preferences, config, counters, flagsctx.storage
Store JSON state (e.g., todo-list nodes, session metadata)ctx.storage
Write files (images, PDFs, text, binary)ctx.volume
Share files publiclyctx.volume + ctx.share

Methods

interface StorageAccess {
  /** Get a stored value by key. Returns null if not found. */
  get(key: string): Promise<unknown | null>;

  /** Set a value by key. Returns true on success. */
  set(key: string, value: unknown): Promise<boolean>;

  /** Delete a value by key. Returns true if the key existed. */
  delete(key: string): Promise<boolean>;

  /** List stored keys, optionally filtered by a prefix. */
  list(prefix?: string): Promise<string[]>;
}

Example: save and retrieve

app.tool('save_preferences', {
  description: 'Save user preferences to key-value storage',
  parameters: {
    type: 'object',
    properties: {
      theme: { type: 'string', enum: ['light', 'dark'] },
      notifications: { type: 'boolean' },
    },
    required: ['theme'],
  },
  handler: async (args, ctx) => {
    const prefs = args as { theme: string; notifications?: boolean };

    // Store structured data
    await ctx.storage.set('user:preferences', prefs);

    // Retrieve it
    const stored = await ctx.storage.get('user:preferences');
    ctx.log('info', `Saved preferences: ${JSON.stringify(stored)}`);

    return { saved: true, preferences: stored };
  },
});

app.tool('list_user_data', {
  description: 'List all keys under the user: prefix',
  parameters: { type: 'object', properties: {} },
  handler: async (_args, ctx) => {
    const keys = await ctx.storage.list('user:');
    return { keys };
  },
});

Key naming conventions

Use colon-separated prefixes to namespace your keys and avoid collisions:

// Good — namespaced keys
await ctx.storage.set('user:preferences', { theme: 'dark' });
await ctx.storage.set('cache:weather:madrid', { temp: 22 });
await ctx.storage.set('state:lastSync', new Date().toISOString());

// List all keys in a namespace
const userKeys = await ctx.storage.list('user:');
const cacheKeys = await ctx.storage.list('cache:');

ctx.storage is scoped to the installation — each installation of your app gets its own key-value store. The volume, by contrast, is scoped to the workspace and is shared by all installations in that workspace.


Next steps