docs/deployment.md
Deployment
Run locally, use remote S3, deploy services, and publish releases.
Four deployment scenarios, from simplest to most complex.
1. Single Developer, Local
Everything runs on your machine. SQLite for metadata, MinIO (Docker) for file storage.
Prerequisites
Setup
# Install agent-fsbun add -g @desplega.ai/agent-fs
# Initialize (starts MinIO container, creates DB, registers local user)agent-fs init --local
# Verifyagent-fs config showagent-fs write /hello.md --content "Hello from agent-fs"agent-fs cat /hello.mdThis creates ~/.agent-fs/ with:
agent-fs.db— SQLite database (metadata, FTS5 index, embeddings)config.json— S3 endpoint, credentials, embedding settingsagent-fs.pid/agent-fs.log— daemon PID and logs (when running as daemon)
Running as a daemon
agent-fs daemon start # Start background daemonagent-fs daemon status # Check if runningagent-fs daemon stop # Stop daemonThe daemon serves both the HTTP REST API and the MCP endpoint on 127.0.0.1:7433. The CLI and agent-fs mcp proxy both require a running daemon.
2. Single Developer, Remote S3
Use Cloudflare R2, AWS S3, Tigris, or any S3-compatible storage instead of local MinIO.
agent-fs talks to every remote store through its S3 client (AWS SDK v3), so a store works only if it offers an S3-compatible API. The local filesystem backend (agent-fs onboard --filesystem) is the one exception: it stores files through the files-sdk fs adapter. agent-fs does not use files-sdk's other provider adapters, so stores with only a native API, such as Convex File Storage, are not supported.
Setup
agent-fs init --local
# Then configure remote S3agent-fs config set s3.endpoint "https://<account-id>.r2.cloudflarestorage.com"agent-fs config set s3.bucket "agent-fs"agent-fs config set s3.accessKeyId "<your-access-key>"agent-fs config set s3.secretAccessKey "<your-secret-key>"agent-fs config set s3.region "auto"S3 Provider Notes
| Provider | endpoint | region | forcePathStyle |
|---|---|---|---|
| MinIO (local) | http://localhost:9000 | us-east-1 | true |
| Cloudflare R2 | https://<account>.r2.cloudflarestorage.com | auto | true |
| AWS S3 | https://s3.<region>.amazonaws.com | your region | false |
| DigitalOcean Spaces | https://<region>.digitaloceanspaces.com | your region | false |
| Tigris | https://t3.storage.dev | auto | false |
| Bunny Storage (S3 API, preview) | https://<region>-s3.storage.bunnycdn.com | your region code (e.g. de) | true |
These rows come from each provider's S3 documentation: Tigris, Bunny Storage. Bunny Storage enables its S3 API only on a new storage zone and does not support object versioning, so there revert returns UNSUPPORTED_OPERATION and diff falls back to the stored change summary. Tigris is the default storage for the Fly.io deploy.
S3 Versioning
Enable S3 versioning on your bucket for full diff and revert support. Without versioning, these operations degrade (no content-level diffs, revert creates from latest only).
# AWSaws s3api put-bucket-versioning --bucket agent-fs --versioning-configuration Status=Enabled
# MinIOmc version enable myminio/agent-fs3. Team, Shared Server
Deploy the HTTP server so multiple developers or agents can share the same filesystem.
Setup
# On the serveragent-fs init --localagent-fs server --host 0.0.0.0 --port 7433Important: The default bind address is
127.0.0.1(localhost only). Use--host 0.0.0.0to accept external connections.
Register users
# Each team member gets their own identitycurl -X POST http://your-server:7433/auth/register \ -H "Content-Type: application/json" \ -d '{"email": "alice@example.com"}'# Returns: { "apiKey": "..." }Client configuration
Each team member configures their CLI or MCP client:
agent-fs config set api.url "http://your-server:7433"agent-fs config set api.key "<their-api-key>"Or via environment variables:
export AGENT_FS_API_URL="http://your-server:7433"export AGENT_FS_API_KEY="<their-api-key>"RBAC
Users have roles per-organization and per-drive:
| Role | Permissions |
|---|---|
viewer | Read files, search, list |
editor | Read + write, edit, delete files |
admin | Full access + manage users, drives, orgs |
Key rules:
- Drive membership is explicit. A drive is only visible and usable for users with a drive membership row. New drives grant the creator admin membership automatically; invite other users per drive (or rely on org-invite's default-drive grant).
- Member management is admin-only. Inviting, listing, updating, and removing org members requires org
admin. Managing drive members requires driveadminor admin of the owning org. Creating drives in an org requires orgadmin. - Drive member listing is viewer-level. Every drive member can list drive member emails and display names with
drive-members. Roles stay admin-only. - Write paths all enforce editor-or-better — the JSON ops route, the binary
PUT /rawroute, and FUSE mounts share the same check. Viewers can read everywhere they're a member but cannot write through any surface. - Org/drive IDs are bound. A request that addresses a drive under the wrong org — or any org/drive the caller has no membership in — returns
404, indistinguishable from a nonexistent ID.
4. Multi-Agent, Hosted
Deploy agent-fs as shared infrastructure for autonomous agents.
Architecture
Agent A (Claude Code) ──┐
Agent B (Cursor) ───┤──→ agent-fs server ──→ SQLite + S3
Agent C (custom) ───┘ :7433Setup
- Deploy server with remote S3 (see scenario 2 for S3 config)
- Register each agent as a user with its own API key
- Create shared drives and assign access via RBAC
- Configure each agent's MCP client with its API key
# Register agentscurl -X POST http://agent-fs:7433/auth/register -d '{"email": "agent-a@agents.local"}'curl -X POST http://agent-fs:7433/auth/register -d '{"email": "agent-b@agents.local"}'Each agent gets its own identity, so file operations are attributed to the agent that performed them. Use log to see who wrote what.
Multi-tenant isolation model
When mutually distrustful users or agents share one server, understand what the boundary is — and is not:
- Isolation is enforced at the application layer by RBAC: every HTTP, MCP, raw, and FUSE operation proves the caller has an explicit role on the target org/drive before touching data. Cross-tenant org/drive/comment IDs resolve to
404, so tenants can't probe each other's resources. - Storage is a single shared S3 bucket, namespaced by
<orgId>/drives/<driveId>/...key prefixes. There is no per-tenant bucket, credential, or encryption key — anyone holding the *server's* S3 credentials (or the server's SQLite DB) can read all tenants' data. Tenant isolation holds only as long as the server host and its credentials are trusted. - Signed URLs are an intentional escape hatch. Generation is RBAC-checked (viewer-or-better on the drive), but the resulting presigned S3 URL is an unauthenticated bearer secret until it expires. A tenant who shares a signed URL is sharing read access to that file with anyone who has the URL.
If you need isolation that survives a server-credential leak, run separate agent-fs instances (or buckets) per tenant.
Embedding Providers
Semantic search requires an embedding provider. Configure via environment variable or config.json.
| Provider | Env Variable | Cost | Notes |
|---|---|---|---|
| OpenAI | OPENAI_API_KEY | ~$0.02/1M tokens | Best quality, requires API key |
| Google Gemini | GEMINI_API_KEY | Free tier available | Good quality, generous free tier |
| OpenRouter | EMBEDDING_PROVIDER=openrouter + OPENROUTER_API_KEY | Depends on model | Defaults to openai/text-embedding-3-small; requires 768-dimensional text embeddings. |
| Local | None | Free | Runs nomic-embed-text-v1.5 on llama.cpp or EmbeddingGemma 2 on transformers.js. Requires a model download. |
Priority: an explicit EMBEDDING_PROVIDER (local, openai, gemini, or openrouter) > OPENAI_API_KEY > GEMINI_API_KEY > config.json > none (semantic search disabled). An explicit hosted provider takes its key from EMBEDDING_API_KEY or config.json first, then from its own API key variable. OPENROUTER_API_KEY alone does not select a provider; select OpenRouter through EMBEDDING_PROVIDER or config.json.
When EMBEDDING_PROVIDER names another provider than config.json, agent-fs does not use the model and API key from config.json. EMBEDDING_MODEL and EMBEDDING_API_KEY still apply.
OpenRouter
Set these variables on the agent-fs server (or local daemon):
EMBEDDING_PROVIDER=openrouterEMBEDDING_MODEL=openai/text-embedding-3-smallEMBEDDING_API_KEY=<your-openrouter-key>OPENROUTER_API_KEY is also accepted when no embedding-specific key is configured. The provider uses https://openrouter.ai/api/v1/embeddings and requests float vectors with 768 dimensions. EMBEDDING_MODEL defaults to openai/text-embedding-3-small. Other text embedding models must support 768-dimensional output; incompatible responses fail validation. Restart after changing the provider or model. The existing background migration regenerates stored embeddings after a successful warmup. It may incur API charges; search can mix old and new vectors until the migration finishes.
With Agent Swarm's Compose deployment, use AGENT_FS_EMBEDDING_PROVIDER, AGENT_FS_EMBEDDING_MODEL, and AGENT_FS_EMBEDDING_API_KEY instead. These configure the agent-fs container, independently of Swarm's memory embeddings. The agent-fs image must include OpenRouter support.
For CLI onboarding, use agent-fs onboard --embeddings openrouter --openrouter-key <key>. Configure a different model afterward with agent-fs config set embedding.model <model>.
The opt-in live test makes billable requests using OPENROUTER_API_KEY:
AGENT_FS_TEST_OPENROUTER=1 bun test packages/core/src/__manual_tests__/openrouter.test.tsText embedding size cap
| Environment variable | Default | Description |
|---|---|---|
AGENT_FS_EMBEDDING_MAX_BYTES | No cap | Positive integer limit for text embeddings, in UTF-8 bytes after base64 stripping (except PDF text). Unset or invalid means no cap. Restart after changes. |
AGENT_FS_EMBEDDING_EXCLUDE_DRIVES | No exclusion | Comma-separated drive IDs. The parser trims IDs and ignores empty entries. Excluded drives keep FTS and lose text and image vectors. Text reports skipped. Restart after changes. |
The cap measures text after base64 stripping. PDF text keeps base64 to preserve page offsets. Text over the cap keeps its full-text search index. The indexer deletes its text chunks and vectors and reports embeddingStatus: "skipped". Image and PDF-page embeddings stay unchanged. At boot, a changed cap queues skipped files and indexed files larger than the new cap. This check uses original FTS bytes and can queue extra files that contain base64. reindex skips files with skipped status, as it skips files with indexed status. stat, CLI, MCP, and the live file metadata show skipped.
Configuring in config.json
{ "embedding": { "provider": "openai", "model": "text-embedding-3-small", "apiKey": "sk-..." }}Configuration Reference
The config file lives at ~/.agent-fs/config.json (or $AGENT_FS_HOME/config.json).
{ "s3": { "endpoint": "http://localhost:9000", "bucket": "agent-fs", "region": "us-east-1", "accessKeyId": "minioadmin", "secretAccessKey": "minioadmin", "forcePathStyle": true }, "embedding": { "provider": "openai", "model": "text-embedding-3-small", "apiKey": "sk-..." }, "server": { "host": "127.0.0.1", "port": 7433 }}Upgrading
Automatic file path normalization
Versions up to 0.15.x could store a path without its leading slash. For example, notes.md and /notes.md could exist as two different files. The server now changes every path to the / form before it uses it. When the daemon starts, it also rewrites the old bare paths in the database. You do not have to do anything.
What the daemon does at startup, before it accepts requests:
- It finds paths without a leading
/in files, versions, comments, shares, search chunks, and full-text rows. - It renames each bare path to its
/form. - If both forms have history, it merges them into one file. It orders the versions by time and numbers them again. The comments move to the merged file.
- It keeps the search rows of the newest form. A text file without search rows goes back to
pending, so the indexer builds them again. - It skips a path that has no safe
/form (a trailing/or a//). It logs the count and up to 10 examples.
All changes occur in one transaction. On a 1 GB database with about 1,800 bare paths, it took about 4 seconds on a laptop SSD. A slower disk takes longer. The server does not answer /health until the migration ends, so give your health check a grace period. After the first run, the check at each start takes milliseconds.
When there is nothing to change, the daemon logs nothing. When it changes data, it logs one line:
file path migration: 62 renamed, 24 merged, 180 versions renumbered, 3 comments remapped, 0 skippedBefore you upgrade:
- Make a backup of
agent-fs.db. For example:sqlite3 ~/.agent-fs/agent-fs.db ".backup agent-fs-backup.db". - To see how many rows have a bare path, run this read-only query:
``bash sqlite3 ~/.agent-fs/agent-fs.db "SELECT (SELECT COUNT(*) FROM files WHERE path NOT LIKE '/%') AS files, (SELECT COUNT(*) FROM file_versions WHERE path NOT LIKE '/%') AS versions, (SELECT COUNT(*) FROM comments WHERE path NOT LIKE '/%') AS comments" ``
If all three counts are 0, the migration has almost certainly nothing to do.
If the migration fails, the transaction rolls back and the daemon logs file path migration failed (will retry on next start). The daemon then starts normally. Files that are only in the bare form stay hidden until a start completes the migration. The daemon tries again at each start.
If you go back to an older version, it reads the migrated data correctly. If the older version writes new bare paths, the next start of a new version migrates them.
Only the server runs this migration: the local daemon, or the server process in a hosted deployment. agent-fs init and the other CLI commands do not change existing paths.
Troubleshooting
"SQLITEERROR: no such module: fts5"
On macOS, Apple's bundled SQLite doesn't support extensions. Install via Homebrew:
brew install sqliteBun will use the Homebrew version automatically.
MinIO container won't start
Check if port 9000 is already in use:
lsof -i :9000The MinIO container is named agent-fs-minio. Check its status:
docker ps -a --filter name=agent-fs-miniodocker logs agent-fs-minioDaemon won't start
Check for stale PID file:
cat ~/.agent-fs/agent-fs.pidkill -0 $(cat ~/.agent-fs/agent-fs.pid) 2>/dev/null && echo "running" || echo "stale"If stale, remove the PID file and restart:
rm ~/.agent-fs/agent-fs.pidagent-fs daemon start