FileSystem Setup Guide
Copy MarkdownThis guide covers how to set up, configure, and populate the FileSystemServer so agents can read and write files. It explains the relationship between the filesystem process, the FileSystem middleware, and your application code.
Overview
The FileSystem middleware gives agents tools like ls, read_file, write_file, and delete_file. But the middleware itself holds no file data — it is a thin client that delegates every operation to a FileSystemServer GenServer, identified by a scope key like {:user, 123}.
This means:
FileSystemServermust be started before the agent runs. The middleware will look up the server by scope key and fail if it isn't running.FileSystemServeroutlives individual agent sessions. It runs under its own supervisor (FileSystemSupervisor), independent of agent lifecycles.- Multiple conversations can share one filesystem. Any agent configured with
filesystem_scope: {:user, 123}talks to the same server and sees the same files.
Your Application
└── FileSystemSupervisor (DynamicSupervisor)
└── FileSystemServer {:user, 1} ← started by your app, not by the agent
└── FileSystemServer {:user, 2}
└── FileSystemServer {:project, 42}
└── AgentsDynamicSupervisor
└── AgentSupervisor (conversation-101)
└── AgentServer ← uses FileSystemServer {:user, 1} via scope key
└── AgentSupervisor (conversation-102)
└── AgentServer ← also uses FileSystemServer {:user, 1}The Three Steps
1. Start the FileSystemServer
Call FileSystem.ensure_filesystem/3 before starting any agent session. This is idempotent — safe to call on every page load or connection.
alias Sagents.FileSystem
alias Sagents.FileSystem.FileSystemConfig
alias Sagents.FileSystem.Persistence.Disk
scope_key = {:user, user_id}
{:ok, fs_config} = FileSystemConfig.new(%{
base_directory: "Memories",
persistence_module: Disk,
debounce_ms: 5_000,
storage_opts: [path: "/var/lib/myapp/user_files/#{user_id}"]
})
{:ok, _pid} = FileSystem.ensure_filesystem(scope_key, [fs_config])On startup, the Disk persistence module calls list_persisted_files/2 to discover all existing files in the storage path and registers them in the server with loaded: false. Files are then loaded lazily on first access — the server only reads the actual content from disk when the agent calls read_file.
2. Pass the scope to the agent
Pass the same scope key to your Factory when creating the agent:
{:ok, agent} = MyApp.Agents.Factory.create_agent(
agent_id: agent_id,
filesystem_scope: {:user, user_id}
)In your Factory, this is forwarded to the FileSystem middleware:
defp build_middleware(filesystem_scope, ...) do
[
{Sagents.Middleware.FileSystem, [filesystem_scope: filesystem_scope]},
...
]
end3. Start the agent session
The agent connects to the already-running FileSystemServer automatically through the scope key. No further setup is needed.
{:ok, session} = MyApp.Agents.Coordinator.start_conversation_session(
conversation_id,
filesystem_scope: {:user, user_id}
)Where to Start the Filesystem
The filesystem must be started before the agent, so the right place is wherever your application first needs it — typically the LiveView mount/3.
# In your LiveView
def mount(_params, _session, socket) do
user_id = socket.assigns.current_scope.user.id
filesystem_scope =
case MyApp.Agents.Setup.ensure_user_filesystem(user_id) do
{:ok, scope} -> scope
{:error, reason} ->
Logger.warning("Failed to start filesystem: #{inspect(reason)}")
nil
end
{:ok, assign(socket, filesystem_scope: filesystem_scope)}
endSince ensure_filesystem/3 is idempotent, it is safe to call on every mount. If the FileSystemServer is already running for that scope key, the call is a no-op that returns the existing PID.
The filesystem continues running after the LiveView disconnects and the agent shuts down due to inactivity. It persists for the lifetime of your application process (or until explicitly stopped), so returning users find their files intact.
Scoping Strategies
The scope key determines how files are isolated and shared:
| Scope | Key format | Files visible to |
|---|---|---|
| Per-user | {:user, user_id} | All conversations for that user |
| Per-project | {:project, project_id} | All agents working on that project |
| Per-conversation | {:agent, agent_id} | Only that specific conversation |
| Custom | {:team, team_id} | Any tuple you choose |
For most user-facing chat applications, user-scoped is the right default. It lets the agent's files accumulate across conversations, creating a persistent long-term memory.
Seeding Files for New Users
When a user's storage directory doesn't exist yet, you can copy template files to give them a helpful starting point. Check for the directory before starting the filesystem:
defmodule MyApp.Agents.Setup do
alias Sagents.FileSystem
alias Sagents.FileSystem.{FileSystemConfig, Persistence.Disk}
def ensure_user_filesystem(user_id) do
storage_path = user_storage_path(user_id)
scope_key = {:user, user_id}
# Seed template files for new users before starting the server
if not File.exists?(storage_path) do
File.mkdir_p!(storage_path)
seed_new_user_files(storage_path)
end
{:ok, fs_config} = FileSystemConfig.new(%{
base_directory: "Memories",
persistence_module: Disk,
debounce_ms: 5_000,
storage_opts: [path: storage_path]
})
case FileSystem.ensure_filesystem(scope_key, [fs_config]) do
{:ok, _pid} -> {:ok, scope_key}
{:error, reason} -> {:error, reason}
end
end
defp seed_new_user_files(storage_path) do
template_path = Path.join(:code.priv_dir(:my_app), "new_user_template")
if File.exists?(template_path) do
copy_directory_contents(template_path, storage_path)
end
end
defp copy_directory_contents(source, dest) do
{:ok, entries} = File.ls(source)
Enum.each(entries, fn entry ->
src = Path.join(source, entry)
dst = Path.join(dest, entry)
if File.dir?(src) do
File.mkdir_p!(dst)
copy_directory_contents(src, dst)
else
File.cp!(src, dst)
end
end)
end
defp user_storage_path(user_id) do
base = Application.get_env(:my_app, :user_files_path, "user_files")
Path.join([base, to_string(user_id), "memories"])
end
endPlace template files in priv/new_user_template/ in your application. They are copied once when the directory is first created and left alone for returning users.
Database-Backed Persistence
For production applications, you may want to store files in your database instead of on disk. Implement the Sagents.FileSystem.Persistence behaviour:
defmodule MyApp.FileSystem.DBPersistence do
@behaviour Sagents.FileSystem.Persistence
alias MyApp.{Repo, UserFile}
import Ecto.Query
@impl true
def list_persisted_files(_scope_key, opts) do
user_id = Keyword.fetch!(opts, :user_id)
paths =
UserFile
|> where([f], f.user_id == ^user_id)
|> select([f], f.path)
|> Repo.all()
{:ok, paths}
end
@impl true
def load_from_storage(entry, opts) do
user_id = Keyword.fetch!(opts, :user_id)
case Repo.get_by(UserFile, user_id: user_id, path: entry.path) do
nil ->
{:error, :enoent}
file ->
{:ok, %{entry | content: file.content, loaded: true, dirty: false}}
end
end
@impl true
def write_to_storage(entry, opts) do
user_id = Keyword.fetch!(opts, :user_id)
result =
case Repo.get_by(UserFile, user_id: user_id, path: entry.path) do
nil ->
%UserFile{}
|> UserFile.changeset(%{user_id: user_id, path: entry.path, content: entry.content})
|> Repo.insert()
existing ->
existing
|> UserFile.changeset(%{content: entry.content})
|> Repo.update()
end
case result do
{:ok, _} -> {:ok, %{entry | dirty: false}}
{:error, changeset} -> {:error, changeset}
end
end
@impl true
def delete_from_storage(entry, opts) do
user_id = Keyword.fetch!(opts, :user_id)
UserFile
|> where([f], f.user_id == ^user_id and f.path == ^entry.path)
|> Repo.delete_all()
:ok
end
endConfigure it in place of Disk:
{:ok, fs_config} = FileSystemConfig.new(%{
base_directory: "Memories",
persistence_module: MyApp.FileSystem.DBPersistence,
debounce_ms: 5_000,
storage_opts: [user_id: user_id]
})The storage_opts keyword list is passed through to every persistence callback, so you can include whatever context your implementation needs (user_id, tenant_id, S3 bucket name, etc.).
Default Configs — Catch-All Persistence
When you want a single persistence backend to handle all file paths (not just a specific directory), set default: true on your FileSystemConfig. This makes it a catch-all that matches any path not claimed by a more specific config.
With default: true, the base_directory field is optional — if omitted, a sentinel value is used internally. This is useful when all files should go to one backend regardless of path:
# All files persisted to DB, no directory restriction
{:ok, fs_config} = FileSystemConfig.new(%{
default: true,
persistence_module: MyApp.FileSystem.DBPersistence,
debounce_ms: 5_000,
storage_opts: [user_id: user_id]
})
{:ok, _pid} = FileSystem.ensure_filesystem({:user, user_id}, [fs_config])With this setup, the agent can write to any path — /notes.txt, /projects/plan.md, /deep/nested/file.txt — and all files are persisted through the same backend.
You can still provide an explicit base_directory with default: true if you want a meaningful label, but it is not required and has no effect on path matching.
Multiple Persistence Backends
A single FileSystemServer can serve files from multiple backends simultaneously. Pass a list of FileSystemConfig structs, one per virtual directory:
# Writable user memories stored on disk
{:ok, memories_config} = FileSystemConfig.new(%{
base_directory: "Memories",
persistence_module: Disk,
debounce_ms: 5_000,
storage_opts: [path: "/var/lib/myapp/user_files/#{user_id}"]
})
# Read-only shared reference documents from S3
{:ok, reference_config} = FileSystemConfig.new(%{
base_directory: "Reference",
persistence_module: MyApp.S3Persistence,
readonly: true,
storage_opts: [bucket: "my-app-shared", prefix: "reference-docs/"]
})
{:ok, _pid} = FileSystem.ensure_filesystem(
{:user, user_id},
[memories_config, reference_config]
)The agent will see both directories via ls but will receive an error if it tries to write to /Reference/.
Default Config with Specific Overrides
You can combine a default: true catch-all with specific directory overrides. The specific configs take priority for their directories, and everything else falls through to the default:
# Default: all files go to DB
{:ok, default_config} = FileSystemConfig.new(%{
default: true,
persistence_module: MyApp.FileSystem.DBPersistence,
storage_opts: [user_id: user_id]
})
# Override: /Reference/* served read-only from S3
{:ok, reference_config} = FileSystemConfig.new(%{
base_directory: "Reference",
persistence_module: MyApp.S3Persistence,
readonly: true,
storage_opts: [bucket: "my-app-shared", prefix: "reference-docs/"]
})
{:ok, _pid} = FileSystem.ensure_filesystem(
{:user, user_id},
[default_config, reference_config]
)With this setup:
/Reference/guide.pdf→ read-only S3 config (specific match wins)/notes.txt→ writable DB config (default catch-all)/projects/plan.md→ writable DB config (default catch-all)
Pre-Populating Files at Runtime
For files that don't come from a persistence backend — for example, injecting dynamic context before a conversation begins — use FileSystemServer.register_files/2:
alias Sagents.FileSystemServer
alias Sagents.FileSystem.FileEntry
scope_key = {:user, user_id}
# Ensure the server is running first
{:ok, _pid} = FileSystem.ensure_filesystem(scope_key, [fs_config])
# Inject a dynamic file into memory (not persisted)
{:ok, entry} = FileEntry.new_memory_file(
"/session/context.md",
"User's current project: #{project.name}\nRecent activity: ..."
)
FileSystemServer.register_files(scope_key, entry)Memory-only files (created with FileEntry.new_memory_file/2) exist only for the lifetime of the FileSystemServer process and are not written to any persistence backend.
Real-Time File Change Notifications
Subscribe to the FileSystemServer's PubSub topic to receive events when the agent creates, edits, or deletes files. This is useful for updating a file browser in your UI.
Requires the server to be started with a PubSub configuration:
{:ok, _pid} = FileSystem.ensure_filesystem(
scope_key,
[fs_config],
pubsub: {Phoenix.PubSub, MyApp.PubSub}
)Then subscribe in your LiveView:
# In mount/3 (connected socket only)
if connected?(socket) do
FileSystemServer.subscribe(scope_key)
end
# Handle events
def handle_info({:file_system, {:file_updated, path}}, socket) do
# Refresh your file list UI
{:noreply, update_file_list(socket)}
end
def handle_info({:file_system, {:file_deleted, path}}, socket) do
{:noreply, update_file_list(socket)}
endReference: agents_demo
The agents_demo project demonstrates all of the above patterns in a complete Phoenix application:
| File | Role |
|---|---|
lib/agents_demo/agents/demo_setup.ex | ensure_user_filesystem/1 — starts the FileSystemServer with disk persistence, seeds template files for new users |
lib/agents_demo_web/live/chat_live.ex | Calls DemoSetup.ensure_user_filesystem/1 in mount/3, subscribes to file change events, passes the scope to the Coordinator |
lib/agents_demo/agents/coordinator.ex | Receives filesystem_scope from the LiveView and forwards it to the Factory |
lib/agents_demo/agents/factory.ex | Passes filesystem_scope to {Sagents.Middleware.FileSystem, [filesystem_scope: scope]} |
priv/new_user_template/ | Template files copied to new users' storage directories |