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:
| Capability | What it's for | Scope |
|---|---|---|
ctx.volume | Files (binary, text, images, any format) | Per workspace |
ctx.share | Public URLs for volume files | Per workspace |
ctx.storage | Structured 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/workspaceare 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:
| Mode | mutable | Behavior |
|---|---|---|
| Snapshot (default) | false | The 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. |
| Live | true | The 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: trueto 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
| Need | Use |
|---|---|
| Store user preferences, config, counters, flags | ctx.storage |
| Store JSON state (e.g., todo-list nodes, session metadata) | ctx.storage |
| Write files (images, PDFs, text, binary) | ctx.volume |
| Share files publicly | ctx.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
- Implement tools — register tools and use the full ToolContext
- Define the manifest — configure
mountVolumefor direct filesystem access - Advanced — events, OAuth, and cross-app intents