IStorage
Last updated April 7, 2026
Blob and file storage interface with optional streaming and metadata.
IStorage is the blob/file storage interface. Unlike ICache, it's for durable data — artifacts, reports, uploaded files, snapshots. Everything is binary (Buffer) at the interface level; callers handle text encoding themselves.
Source of truth: platform/kb-labs-core/packages/core-platform/src/adapters/storage.ts.
Interface
interface IStorage {
// Core methods (required)
read(path: string): Promise<Buffer | null>;
write(path: string, data: Buffer): Promise<void>;
delete(path: string): Promise<void>;
list(prefix: string): Promise<string[]>;
exists(path: string): Promise<boolean>;
// Extended methods (optional)
readStream?(path: string): Promise<NodeJS.ReadableStream | null>;
writeStream?(path: string, stream: NodeJS.ReadableStream): Promise<void>;
copy?(sourcePath: string, destPath: string): Promise<void>;
move?(sourcePath: string, destPath: string): Promise<void>;
listWithMetadata?(prefix: string): Promise<StorageMetadata[]>;
stat?(path: string): Promise<StorageMetadata | null>;
}The core five are required by every adapter. The six extended methods are opt-in — when they're missing, the runtime falls back to composing the core methods, so callers can always rely on the full surface.
Core methods
read(path)
Returns the file contents as a Buffer, or null if the file doesn't exist. Callers handle encoding explicitly:
const buf = await storage.read('releases/v1.0.0.json');
if (buf) {
const data = JSON.parse(buf.toString('utf-8'));
}write(path, data)
Writes a buffer to the given path. Overwrites existing files. Creates parent directories as needed (this is a convention every first-party adapter follows).
const data = JSON.stringify({ version: '1.0.0' });
await storage.write('releases/v1.0.0.json', Buffer.from(data, 'utf-8'));delete(path)
Removes a file. Missing paths are not an error — adapters silently succeed. If you need to know whether a delete actually removed something, call exists() first.
await storage.delete('releases/v1.0.0.json');list(prefix)
Returns all file paths under the given prefix. The prefix is required — there is no "list everything" form. Pass '' (empty string) if you really want the root.
const files = await storage.list('releases/');
// ['releases/v1.0.0.json', 'releases/v1.0.1.json', ...]Adapters may return paths relative to the prefix or absolute — the contract doesn't specify, so don't write code that depends on either. Use path.basename() if you need just the filename.
exists(path)
Returns true if the file exists, false otherwise.
if (!(await storage.exists('releases/v1.0.0.json'))) {
await storage.write('releases/v1.0.0.json', Buffer.from('{}'));
}Extended methods
These are optional for performance — when absent, the runtime composes them from the core methods.
readStream(path), writeStream(path, stream)
For large files. When absent, the runtime buffers the entire file in memory, so implementing these is worth it for anything above ~10MB.
if (storage.readStream) {
const stream = await storage.readStream('large-file.bin');
if (stream) {
for await (const chunk of stream) {
// ... process chunk
}
}
}copy(sourcePath, destPath), move(sourcePath, destPath)
Server-side copy/move. On adapters where the backend supports it natively (S3 copy, POSIX rename), this is orders of magnitude faster than read + write + delete. The fallback uses exactly that sequence.
listWithMetadata(prefix)
Returns an array of StorageMetadata instead of just paths:
interface StorageMetadata {
path: string;
size: number;
lastModified: string; // ISO 8601
contentType?: string; // MIME type, if the adapter knows it
etag?: string; // versioning tag
}The fallback is list() + stat() per file, which is fine for small directories but horrifying for large ones. Implement this whenever you can query metadata from the backend in bulk.
stat(path)
Returns metadata for a single file without reading its contents. Fallback is exists() + read(), which reads the whole file just to get its size — implement stat if your backend gives you a cheap metadata query.
Contract rules
- Everything is
Buffer, notstring. Adapters must return binary. Text handling is the caller's responsibility. read()returnsnullon missing files. It doesn't throw. This matchesICache.get()semantics.delete()is idempotent. Deleting a missing file is a success, not an error.list(prefix)requires a prefix. There is no "list all" form — pass''if you want the root.- Path separators are
/. Even on Windows adapters. Adapters handle platform-specific conversion internally. - Paths are relative to the adapter's root. The root is configured via
adapterOptions[token].basePath(or equivalent). Plugins never see absolute paths.
Permissions enforcement
Plugins declare their storage paths via permissions.platform.storage:
.withPlatform({
storage: {
read: ['releases/**'],
write: ['releases/**', 'artifacts/**'],
},
})Or the shorter form for read-write access to the same paths:
.withPlatform({ storage: ['releases/**', 'artifacts/**'] })Or true for full access (discouraged except for trusted system plugins).
The runtime enforces these globs in a wrapper layer above the adapter — adapters themselves don't know about permissions.
useStorage() vs ctx.runtime.fs
These are not the same thing, and picking the wrong one is a common mistake:
useStorage()— theIStorageadapter. Durable, swappable backends (local FS, S3, snapshot storage). Use this for anything the plugin wants to persist beyond a single execution.ctx.runtime.fs— the sandboxed filesystem access for the plugin's working directory. Backed by the real local filesystem, gated bypermissions.fs. Use this for reading project files the plugin is analyzing or modifying.
Rule of thumb: if you'd want to move the file to S3 later, use useStorage(). If it's "the source code the plugin is reviewing", use ctx.runtime.fs.
Writing an IStorage adapter
A minimal local-filesystem adapter:
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import type { AdapterManifest, AdapterFactory, IStorage, StorageMetadata } from '@kb-labs/core-platform';
export const manifest: AdapterManifest = {
manifestVersion: '1.0.0',
id: 'fs-storage',
name: 'Filesystem Storage',
version: '0.1.0',
type: 'core',
implements: 'IStorage',
capabilities: {},
};
interface FsStorageConfig {
basePath: string;
}
class FsStorage implements IStorage {
constructor(private basePath: string) {}
private resolve(p: string): string {
return path.resolve(this.basePath, p);
}
async read(p: string): Promise<Buffer | null> {
try {
return await fs.readFile(this.resolve(p));
} catch (err: any) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
async write(p: string, data: Buffer): Promise<void> {
const full = this.resolve(p);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, data);
}
async delete(p: string): Promise<void> {
try {
await fs.unlink(this.resolve(p));
} catch (err: any) {
if (err.code !== 'ENOENT') throw err;
}
}
async list(prefix: string): Promise<string[]> {
const dir = this.resolve(prefix);
try {
const entries = await fs.readdir(dir, { recursive: true });
return entries.map(e => path.posix.join(prefix, e.toString()));
} catch (err: any) {
if (err.code === 'ENOENT') return [];
throw err;
}
}
async exists(p: string): Promise<boolean> {
try {
await fs.access(this.resolve(p));
return true;
} catch {
return false;
}
}
async stat(p: string): Promise<StorageMetadata | null> {
try {
const st = await fs.stat(this.resolve(p));
return {
path: p,
size: st.size,
lastModified: st.mtime.toISOString(),
};
} catch {
return null;
}
}
}
export const createAdapter: AdapterFactory<FsStorageConfig, {}, IStorage> = (config) => {
return new FsStorage(config.basePath);
};Built-in adapters implementing IStorage
| Package | Notes |
|---|---|
@kb-labs/adapters-fs | Local filesystem, rooted at config.basePath. The default for dev and single-node deployments. |
Future adapters (S3, GCS, snapshot-backed) will implement the same interface.
What to read next
- SDK → Hooks →
useStorage— how plugins consumeIStorage. - Plugins → Permissions →
platform.storage— declaring storage paths.