Skip to content

Server

Mount a shared volume

AI-assistedThis page includes both human and AI contributions.

How to give programs a directory that outlives sessions and is shared across Blueprints: declare a named volume on the server, mount it in a Blueprint read-only or read-write, use it from a program, and know where its files live.

A session’s files end with the session. Some things an agent works with must outlive it, such as the notes it keeps between conversations, a handbook every agent should be able to read, or a workspace two Blueprints share. For those there is the named volume. The server owns and declares it, a Blueprint mounts it at a path it chooses, read-only or read-write, and every session and Blueprint that mounts it sees it.

This guide shows you how to mount a shared volume. The example gives Acme’s support agent a memory it keeps across conversations and a read-only company handbook. Substitute your volumes.

Volumes are declared in the server’s config file, under volumes:, and nowhere else. A Blueprint can only name them. Each has a kind, a size limit, and the most access any Blueprint may have:

server.yaml (fragment)
volumes:
project-memory:
kind: managed-local
size_limit: 100MB
company-handbook:
kind: local-path
path: /srv/company-handbook
access: read_only
size_limit: unlimited
Kind Where its files live
managed-local A directory the server creates the first time a program uses the volume, under volume_dir ($SUBMILLI_HOME/server/volumes/<name> by default)
local-path The directory at path, which you created and the server never creates or deletes

size_limit is required, a size such as 100MB or 10GB, or unlimited. One limit covers the volume however many sessions and Blueprints use it, so a Blueprint can’t get a second allowance by mounting the same volume twice. access is read_write unless you write read_only. The server reads the declarations at startup, so restart it after a change.

A Blueprint mounts a volume at a path under vfs.mounts, beside its root, which stays ephemeral or per_session as Keep files and state describes. The support agent keeps its session files at /, its memory at /memory, and reads the handbook at /handbook:

blueprint.yaml (fragment)
vfs:
mode: per_session
mounts:
/memory:
mode: named
volume: project-memory
access: read_write
/handbook:
mode: named
volume: company-handbook
access: read_only

access can only narrow what the server declared. Leave it out to take the server’s setting. A volume can also be the root itself, vfs: {mode: named, volume: project-memory}, for a Blueprint whose filesystem should outlive the session. The Blueprint’s filesystem rules apply under a mount as anywhere else. The example allows fs.read, fs.write, fs.stat, and fs.list to main.

Registration checks the mounts against the declarations:

Terminal window
submilli server blueprint apply blueprint.yaml
Added blueprint 'support'

A volume the server doesn’t declare, or more access than it allows, is refused with the fix:

error: volume 'team-memory' is not declared on this server; declared volumes: company-handbook, project-memory
error: volume 'company-handbook' is read_only on this server; drop `access: read_write` (or write `access: read_only`), or ask the operator to declare it read_write

A Blueprint written for the earlier persistent mode is refused too, with the edit that replaces it:

error: blueprint parse error: vfs.mode: vfs mode `persistent` was removed: write `mode: named` and keep the `volume:` line (`vfs: {mode: named, volume: <name>}`). A named volume keeps its files across calls, sessions and restarts as before; leave `access:` out to keep the access the server declares for it at line 4 column 9

A program reads and writes under a mount with the ordinary submilli:fs calls. This one reads the refund policy and adds a line to the agent’s notes:

remember.ts
import fs from "submilli:fs";
function main(): string {
const policy = fs.readText("/handbook/refunds.md") ?? "";
fs.appendText("/memory/notes.md", "Northwind asked about refunds; pointed them to the policy.\n");
const notes = fs.readText("/memory/notes.md") ?? "";
return `policy: ${policy.split("\n")[2]}\nnotes so far: ${notes.split("\n").length - 1}`;
}

Run it twice. Each run-code opens a new session, so the session’s files start empty each time, and the notes under /memory don’t:

Terminal window
submilli server run-code remember.ts --blueprint support
submilli server run-code remember.ts --blueprint support
policy: A goodwill credit needs a ticket number and may not exceed $50 without a lead's approval.
notes so far: 1
policy: A goodwill credit needs a ticket number and may not exceed $50 without a lead's approval.
notes so far: 2

A write under a read-only mount is refused with a PermissionDeniedError the program can catch, before the file is touched:

scribble.ts
import fs from "submilli:fs";
function main(): string {
fs.writeText("/handbook/refunds.md", "No refunds.");
return "rewrote the handbook";
}
error: PermissionDeniedError: permission denied: caller=main capability=fs.write: /handbook/refunds.md is in the volume mounted read-only at /handbook. This volume cannot be written from this blueprint; write under a writable path instead (fs.info() lists each mount and its access).
fields: caller = "main", capability = "fs.write", reason = "/handbook/refunds.md is in the volume mounted read-only at /handbook"
at main (<execute>:4:42) [thrown here]
3 | function main(): string {
4 | fs.writeText("/handbook/refunds.md", "No refunds.");
| ^
5 | return "rewrote the handbook";

fs.info() reports the root’s mode, access, and size limit, and each mount’s path, volume, access, and limit, with -1 where no limit applies:

info.ts
import fs from "submilli:fs";
function main(): string {
const info = fs.info();
const lines = [`/ ${info.mode} ${info.access} limit=${info.sizeLimit.toString()}`];
for (const mount of info.mounts) {
lines.push(`${mount.path} ${mount.volume} ${mount.access} limit=${mount.sizeLimit.toString()}`);
}
return lines.join("\n");
}
/ per_session read_write limit=-1
/handbook company-handbook read_only limit=-1
/memory project-memory read_write limit=104857600

Mount points can’t be moved or removed, and one mount can’t sit inside another. The same volume may be mounted at two paths, under one limit. A move between the root and a mount, or between two mounts, copies and then removes, so it isn’t atomic. A named volume needs a server to resolve it, so submilli run refuses a Blueprint that mounts one and says what to do instead:

error: blueprint.yaml: blueprint 'support' uses named volume 'company-handbook' at `/handbook`; named volumes are declared in a server config, so run it on `submilli-server`, or drop the volume for local runs (use `--vfs <dir>` to give the program a directory)

One memory every session shares suits a handbook but not notes about customers. The agent serving Northwind shouldn’t read what it noted about Initech. Mount only that customer’s directory of the volume, chosen by the variable the application binds for the session:

blueprint.yaml (fragment)
variables:
customerId:
required: true
vfs:
mode: per_session
cwd: /memory
mounts:
/memory:
mode: named
volume: project-memory
subPath: customers/${vars.customerId}

subPath names the directory inside the volume to mount. The program sees it as /memory whichever customer the session is for, and nothing above it, so neither the program nor a Package it calls can reach another customer’s notes, and no rule has to name a customer. ${vars.customerId} must be a whole part of the path, and a writable mount creates the directory the first time it is used. cwd is where relative paths start, so this program’s notes.md is /memory/notes.md:

remember.ts
import fs from "submilli:fs";
function main(): string {
fs.appendText("notes.md", "Asked about refunds; pointed them to the policy.\n");
const notes = fs.readText("notes.md") ?? "";
return `${fs.cwd()}/notes.md: ${notes.split("\n").length - 1} lines`;
}

Run it twice for Northwind, then once for Initech:

Terminal window
submilli server run-code remember.ts --blueprint support --var customerId=cus_northwind
submilli server run-code remember.ts --blueprint support --var customerId=cus_northwind
submilli server run-code remember.ts --blueprint support --var customerId=cus_initech
/memory/notes.md: 1 lines
/memory/notes.md: 2 lines
/memory/notes.md: 1 lines

Notice the third run. The path is the same, and Initech’s notes start at one line. The volume holds a directory per customer:

project-memory/customers/cus_initech/notes.md
project-memory/customers/cus_northwind/notes.md

A session bound to a value that isn’t one directory name, such as .., is refused before any program runs:

invalid vfs config: each path component must be nonempty and contain no separator, NUL, '.' or '..' component

cwd is a convenience. .. and absolute paths still reach the rest of what the Blueprint mounts. The boundary is subPath.

A managed volume’s files are under volume_dir, in a directory named after the volume, and a local-path volume’s are where you put them:

~/.submilli/server/volumes/project-memory/notes.md

Nothing the server does deletes a volume’s files. Ending a session or removing a Blueprint leaves them, and removing the declaration from the config only stops Blueprints from naming the volume. Declare it again and its files are still there. To remove a managed volume for good, delete its directory under volume_dir while the server is stopped. Back up volume_dir with the sessions, as Run the server says. As with a session’s files, nothing can rebuild what programs kept there.