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 for reading and writing files: list_files, read_file, create_file, replace_file_text, replace_file_lines, delete_file, move_file, and find_in_file. 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}What the Filesystem Is For
Before you wire anything in, take a minute on what the FileSystem functionality is — and, more importantly, what it isn't.
What it is. A general-purpose virtual filesystem. It stores files: things addressed by a path, containing text or binary content, with basic metadata (size, created/modified timestamps, mime type). The storage backend is pluggable — disk, database, S3, in-memory — but the shape of what is stored is fixed. Every entry is a path plus some bytes.
What it is not. A domain store. If your application has structured records — BlogPost, Ticket, Invoice, Customer, Order, anything with its own validations, lifecycle, and business rules — those are not files. They have relationships, workflow states, required fields, enum constraints, and invariants the filesystem has no opinion about. Trying to push them through the filesystem layer produces problems the filesystem cannot solve: the LLM has no way to discover your custom attribute names, validation failures corrupt partial state across tool calls, and expensive content-generation tokens get wasted on metadata errors.
Why the temptation exists. The pull is strongest when the record has a large text body — a blog post with article content, a knowledge-base article with body markdown, a ticket with a long description. The text body feels file-shaped because the content is file-shaped. It isn't. The text body is a file; the record that owns it is a domain object. Conflating them is a category error.
The correct pattern. Model your domain in its own schemas with its own persistence, and ship your own middleware exposing tools specific to that domain. The filesystem stays available alongside your domain middleware for legitimate file use cases — drafts, notes, scratch content, user memories, reference documents, anything that genuinely is a file.
Worked example: draft-then-commit. Suppose your application manages BlogPost records — each with a title, author, status (draft or published), tags, and an article body. The body can be hundreds or thousands of tokens, generated by the agent on the user's behalf. A naive workflow that creates the post in a single tool call has a problem: if metadata validation fails for any reason (a typo in the status, a missing required field, a schema rule the LLM didn't know about), the entire call is rejected and the agent has to regenerate the article body on the next attempt. Generated tokens are the most expensive thing in the system; throwing them away on a metadata error is a cost bomb.
The right shape splits content from metadata across two tool calls:
- The agent generates the article body and calls
create_file("/drafts/post-abc.md", content). No domain validation — it's just a file in a drafts directory. - The agent calls
create_blog_post(title: ..., status: "draft", tags: [...], source_file: "/drafts/post-abc.md")— a tool from your application's ownBlogPostMiddleware. That tool reads the source file, validates the metadata against theBlogPostschema, writes to the database on success, and deletes the source file. - If metadata validation fails, the source file is untouched. The agent retries
create_blog_postwith corrected metadata and does not regenerate the body. The cost of the retry is proportional to the metadata payload, not the article body.
The FileSystem middleware knows nothing about blog posts. The BlogPostMiddleware knows nothing about filesystem internals. They compose cleanly because each stays in its lane.
The warning signal. If you find yourself reaching for a way to attach domain-specific attributes to files — validation rules, required fields, enum constraints, custom metadata bags — stop. That's the signal that you're building a domain, not a filesystem. Build it as its own middleware with its own store and its own tools, and let the filesystem stay dumb.
The Three Steps
1. Start the FileSystemServer
Call Sagents.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_entries/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 through the FactoryConfig
Per-request fields like :filesystem_scope are declared as fields on
your generated FactoryConfig struct and threaded in via request_opts.
The FactoryRouter builds the config; the Factory consumes it:
# lib/my_app/agents/factory_config.ex
defmodule MyApp.Agents.FactoryConfig do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :scope, :any, virtual: true
field :conversation_id, :integer
field :filesystem_scope, :any, virtual: true
field :tool_context, :map, default: %{}
end
def from_inputs(inputs), do: cast(%__MODULE__{}, inputs, ~w(...)a)
def build(changeset), do: apply_action(changeset, :insert)
end
# lib/my_app/agents/factory.ex
defmodule MyApp.Agents.Factory do
@behaviour Sagents.Factory
@impl true
def create_agent(agent_id, %MyApp.Agents.FactoryConfig{} = c) do
agent = Sagents.Agent.new!(%{
agent_id: agent_id,
scope: c.scope,
model: build_model(c),
middleware: build_middleware(c),
tool_context: c.tool_context
})
{:ok, agent, []}
end
defp build_middleware(c) do
[
{Sagents.Middleware.FileSystem, [filesystem_scope: c.filesystem_scope]},
# ...
]
end
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,
scope: scope,
request_opts: [filesystem_scope: {:user, user_id}]
)The Coordinator forwards request_opts to the FactoryRouter, which
folds the values into %FactoryConfig{}, which the Factory reads to
configure the FileSystem middleware.
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}
alias Sagents.FileSystem.FileEntry
import Ecto.Query
@impl true
def list_persisted_entries(_scope_key, opts) do
user_id = Keyword.fetch!(opts, :user_id)
entries =
UserFile
|> where([f], f.user_id == ^user_id)
|> Repo.all()
|> Enum.map(fn file ->
{:ok, entry} = FileEntry.new_indexed_file(file.path)
entry
end)
{:ok, entries}
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_content: 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_content: 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 list_files 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_file(
"/session/context.md",
"User's current project: #{project.name}\nRecent activity: ..."
)
FileSystemServer.register_files(scope_key, entry)Files registered at a path that doesn't match any FileSystemConfig live in memory only for the lifetime of the FileSystemServer process and are not written to any persistence backend.
Real-Time File Change Notifications
Subscribe to a FileSystemServer to receive events when the agent creates, edits,
or deletes files. This is useful for updating a file browser in your UI. Events
are delivered directly to the subscriber pid via Sagents.Publisher — no
Phoenix.PubSub configuration required.
# In mount/3 (connected socket only)
if connected?(socket) do
{:ok, _server_pid, _ref} = 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)}
endFileSystemServer.subscribe/1 returns {:ok, server_pid, monitor_ref} on
success or {:error, :process_not_found} if no FileSystemServer is running for
that scope. The producer monitors the subscriber so departure cleans up
automatically.
Reference: 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 |