Skip to content

ctx.storage

Persisted, per-extension storage (ExtensionStorageScopes) in two shapes: key/value bags for settings-sized state, and directories for real data files. Both come in the same two scopes — global and workspace — and both are namespaced to your extension id, shared across all your surfaces (status bar, side panels, settings page).

ts
ctx.storage: ExtensionStorageScopes // { global, workspace, globalDir(), workspaceDir(), workspaceDirs() }
ScopeLifetimeUse for
ctx.storage.globalone bag, shared across all workspacesthe extension's own settings (enabled features, …)
ctx.storage.workspaceone bag per active workspacestate that should differ per workspace
ctx.storage.globalDir()one directory, shared across workspacesdata files (a .jsonl log, a cache, an export)
ctx.storage.workspaceDir()one directory per workspace (the active one by default)per-project data files
ctx.storage.workspaceDirs()every open workspace's directory, in one calla surface that aggregates data across workspaces

Pick a bag for something settings-shaped, and a directory for something a person should be able to find, grep, back up, or point an agent at. A growing list does not belong in a key/value bag — it would be invisible outside Silo and tied to Silo's own persistence format.

Example

Read settings up front in activate and persist on change. get/set are safe to call immediately — but the app state hydrates asynchronously (and the workspace bag is swapped when the active workspace changes), so subscribe and re-read to pick up a value that lands after activate:

ts
export const extension: Extension = {
  id: "my.extension",
  activate(ctx) {
    const store = ctx.storage.global;

    const apply = () => render(store.get<Settings>("settings", DEFAULTS));
    apply();
    ctx.subscriptions.push({ dispose: store.subscribe(apply) });

    // later, from a settings page or panel:
    store.set("settings", next);
  },
};

Storage directories

globalDir() and workspaceDir() each resolve to an absolute path to a directory the host owns on your behalf. The directory is created on first call and lives under Silo's user-config root, namespaced by your extension id:

~/.config/silo/extension-storage/<your-extension-id>/
├── global/            ← ctx.storage.globalDir()
└── workspaces/<id>/   ← ctx.storage.workspaceDir()

(A non-production build is keyed to its own root — "Silo Dev" uses ~/.config/silo-dev/… — so a dev install never shares data with a stable one.)

No filesystem permission is needed inside your own directory

Paths beneath these directories are readable and writable through ctx.files without declaring fs:read or fs:write. Your own storage is inside your sandbox, the same way the open workspace folder is — so an extension whose only filesystem need is its own data file declares no permissions at all, and its install prompt asks for nothing.

The lift stops at ctx.files. Running a command with a working directory inside your own directory still needs the process permission; passing the path to a command as an argument needs nothing extra.

Relative paths still resolve against the workspace

This is the one surprising part. ctx.files.readText("notes.md") resolves against the workspace folder, not your storage directory — always join onto the absolute path the host handed you:

ts
const dir = await ctx.storage.globalDir();
await ctx.files.writeText(`${dir}/tasks.jsonl`, lines.join("\n"));
const back = await ctx.files.readText(`${dir}/tasks.jsonl`);

The workspace directory follows the workspace, not the folder

workspaceDir() is keyed by the workspace's identity, so deleting a workspace and re-adding the same folder gives you a new, empty directory — the same rule ctx.storage.workspace already follows. Call it again after the active workspace changes; don't cache the path across a switch.

With no workspace open — and no id passed — it rejects with NoWorkspaceError, distinct from a PathDeniedError, because nothing was denied:

ts
try {
  const dir = await ctx.storage.workspaceDir();
  await ctx.files.writeText(`${dir}/notes.md`, body);
} catch (err) {
  if (err instanceof NoWorkspaceError) return; // nothing to persist yet
  throw err;
}

Reading across workspaces

To resolve another workspace's directory, pass its id (from ctx.workspaces); to resolve every open workspace's directory at once — the building block for a cross-workspace view — call workspaceDirs(). Both take { create }, defaulting to true. Pass create: false when you only need the path to read from: ctx.files.writeText does not create parent directories, so a caller that writes needs the default, but a reader does not want an empty directory left on disk for every workspace that has no data yet.

ts
// one aggregated list over every workspace's tasks.jsonl
for (const { workspaceId, dir } of await ctx.storage.workspaceDirs({
  create: false,
})) {
  const path = `${dir}/tasks.jsonl`;
  const body = (await ctx.files.pathExists(path))
    ? await ctx.files.readText(path)
    : "";
  // …merge into the aggregated model, keyed by workspaceId
}

workspaceDirs() returns one WorkspaceStorageDir per workspace in ctx.workspaces.getState().open, in no guaranteed order — match entries by workspaceId. Closed workspaces are not included.

Your data outlives your extension

Uninstalling never deletes a storage directory on its own — the uninstall confirm offers it as an unchecked option, and the retained path is written to the Output panel. Reinstalling finds the previous data. Deleting a workspace leaves its per-extension directories alone.

Methods

On each ExtensionStorage scope. Method names link to the full signature.

MethodWhat it does
get(key, fallback?)Read a value; returns fallback (or undefined) when the key is missing.
set(key, value)Write a value. Passing undefined deletes the key.
keys()The keys currently set in this namespace.
subscribe(listener)Observe changes in this namespace (also fires on hydration and workspace swap); returns an unsubscribe function.

On ExtensionStorageScopes itself:

MethodWhat it does
globalDir()Absolute path to your directory, shared across workspaces. Created on first call.
workspaceDir(id?, { create? })Absolute path to your directory for a workspace — the active one by default (rejects with NoWorkspaceError if none), or any workspace by id. create defaults to true.
workspaceDirs({ create? })One { workspaceId, dir } per open workspace, in a single call. create defaults to true.

Types

Pass ExtensionStorageScopes ({ global, workspace, globalDir(), workspaceDir(), workspaceDirs() }), whose two bags are each an ExtensionStorage. workspaceDirs() resolves to WorkspaceStorageDir[]. workspaceDir() rejects with NoWorkspaceError when called with no id and no workspace open.

Related: SidePanelProps exposes the same workspace scope keyed by panel id, for panel-local UI state.

See also

Other Services on ctx.