File (FS)
The File plane reads, writes, edits, searches, transfers, and watches files. It shares one filesystem with commands, terminals, code execution, and browser downloads: a file written through this API is immediately visible to a shell command, and vice versa.
The working directory is not a confinement boundary — file calls reach anywhere the daemon's own account can. Confinement is the sandbox's job, not this API's.
On the file API, user means something different from its meaning on commands and terminals:
On the file API,
useronly decides who owns new files and directories; on commands and terminals, it decides which account runs the process.
The file API still reads and writes with the daemon's own privileges. GET /v1/capabilities reports the file capabilities under files, one flag per operation. The v1 and v2 routes side by side are in Migration from 1.x.
Read and write files
Write body:
Line numbers start at 0, and end_line is exclusive. read takes path, start_line, and end_line as query parameters; write takes a JSON body keyed by path.
The v1 write body uses file for the path:
Both take a JSON body. read, write, replace, and search key the file as file; the other routes use path.
The write body takes these fields:
- A write creates the missing parent directories on the way, and answers
fileandbytes_written. base64decodescontentto bytes;rawwrites each character as one byte.leading_newlineandtrailing_newlineapply toutf-8only.- Reading is buffered and text-only: a directory answers
EISDIR, a non-UTF-8 filedecode_error, and a file over 16 MiBfile exceeds the 16 MiB buffered text limit. Download streams instead, at any size.
Files and directories
create and view are not part of the editor route: both answer 400 naming the route that replaces them, POST /v2/fs/write and GET /v2/fs/read.
The editor tool applies an anchored substitution, so an edit needs neither the whole file nor a line number. It takes command, path, old_str, new_str, insert_line for insert, and replace_mode (ALL, FIRST, LAST):
old_stris matched verbatim, so whitespace and line endings have to agree. An anchor that is absent answers400 old_str not found in file; an anchor that matches twice answers400 old_str has multiple occurrences; replace_mode is requiredrather than picking one.datacarriesoutput(thecat -nsnippet the tool shows a model),old_contentandnew_content(the whole file before and after), andprev_exist.undo_editrestores the content the last edit replaced.
Listing and stat return these fields:
- Each
filesentry fromlistcarriesname,path,is_directory,size,extension, andmodified_time. - The list envelope carries
total_count,file_count,directory_count, andtruncated. Results are cut off at 10,000 entries, withtruncatedset totrue. is_hiddenappears only when it istrue;show_hiddenis on by default.permissionsappears only when requested.statanswerspath,size,permissions,is_directory,is_symlink, andmodified_time.
Symlinks are classified by what they point at: a link to a directory counts as a directory, and a recursive listing lists the link without descending through it. follow_symlinks: false makes stat describe the link itself. Windows attributes and permissions are in Windows.
copy and move refuse an existing destination with already_exists unless overwrite is set, so a rename can never silently clobber. recursive is what lets delete remove a non-empty directory.
Search
Finding a file by name and searching inside files are two different routes.
The grep body is the same in v1 and v2:
grep narrows what it reads with include and exclude globs, type, and max_file_size, and shapes the answer with case_insensitive, multiline, context_before and context_after, max_results, and offset. A match carries file, line_number counted from 1, line_content, and the context lines when they were asked for.
Every walk is bounded and says so through truncated: grep reads at most 500 matches and skips files over 1 MiB, a metadata glob stops at 5,000 entries, and a name search stops at 10,000. Raise max_results where the route takes it, or narrow the root.
Transfer
- Upload:
POST /v2/fs/upload - Download:
GET /v2/fs/download?path=...
- Upload:
POST /v1/file/upload - Download:
GET /v1/file/download?path=...
- An upload spools to disk instead of buffering in memory, so file size is bounded by disk, not RAM. It lands in a
.aiod-upload-<id>.partfile next to the target and is renamed over it. - A download streams the bytes with
Accept-Ranges,ETag, andLast-Modified, honours aRangeheader with a206, and answers a missing path with404. - A download otherwise returns whatever the kernel reads while it streams;
change_policy=abortpins the file's state at open instead, answers409when it had already changed, and cuts a transfer that changes under it.
When a whole directory has to move as one tar stream, switch to v2:
Directory transfer uses a tar stream:
GET /v2/fs/tree?path=...exports a directory as a tar stream.PUT /v2/fs/tree?path=...accepts a tar body and writes it to the destination directory.- The service extracts it in-process, without a
tarbinary. Members with..or an absolute path are rejected; ownership and permissions from the archive are not preserved.
The response's mode shows how the write was applied:
The limits are: a tar body of at most 4 GiB, an extracted total of at most 4 GiB, and at most 100,000 archive entries.
Watch a directory
Use watch to learn that a file changed. Do not poll for it. Treat events as invalidation signals, not content: reload a clean copy, or flag a conflict when unsaved edits exist. Anything that touches the tree is reported — a command, another API call, a build:
The watcher uses the /v2/watch routes:
POST /v2/watchcreates a watcher.GET /v2/watch/{id}/pollpolls events, withcursor,limit, andtimeoutas query parameters.GET /v2/watch/{id}/eventspushes events as Server-Sent Events.DELETE /v2/watch/{id}releases a watcher.GET /v2/watchlists the live watchers.
The watcher uses the /v1/file/watch routes:
POST /v1/file/watchcreates a watcher.POST /v1/file/watch/{id}/pollpolls events, withcursor,limit, andtimeoutin the body.GET /v1/file/watch/{id}/eventspushes events as Server-Sent Events.DELETE /v1/file/watch/{id}releases a watcher.GET /v1/file/watchlists the live watchers.POST /v1/file/watch/waitblocks until one path changes. It takespath,timeout(30 s by default), andevent_types, answers a singleevent, and returns503 timed out waiting for file eventwhen nothing happened.
The knobs and their bounds:
Every event carries these fields:
- Basic information:
seqandtype(create,write,remove,rename, orchmod). - Paths:
pathandrelative_path. - File attributes:
is_dir,timestamp,mtime,size, andinode. - A
renameevent also carries the old path asold_path.
cursor is the position the client has consumed. Pass the response's cursor straight into the next poll. If the buffer drops events before you read them, the response includes overflow: true. cursor: 0 replays buffered history; the initial_cursor returned when the watcher is created means "from now on."
Filtering and writes work as follows:
excludedefaults to.git,node_modules,__pycache__,.venv,.DS_Store, and the usual byte-code and editor leftovers. Passingexcludereplaces that list; it does not extend it.- Do not exclude
*.part: an upload reports three events on its temporary name before therenameon the target. - A write that names an owner stages through
.aiod-write-<id>.partbefore reaching the target. A plain write keeps the destination's inode and creates no temporary file.
Creating a watcher twice with the same configuration reuses the existing watcher. The second create answers reused: true with the current initial_cursor, and each DELETE releases one subscriber. Up to 128 watchers can run at once, each holding the last 10,000 events; past that, a create answers 429. A browser UI that would rather receive pushed events than poll can request the event stream with Accept: text/event-stream.
Ownership identity
user only decides who owns the files and directories created by the call. The file API always reads and writes with the daemon account's privileges.
Add ?user=alice to any /v2/fs request that creates a file or directory, including upload and PUT /v2/fs/tree, to choose the owner.
For example, create a file owned by alice:
Send this body to POST /v2/fs/write?user=alice.
In v1, sudo: true selects the root account. Only read, write, replace, and search accept it.
When a file has to belong to a named account rather than root, switch to v2:
Other rules:
- Omit
userto use the default owner. WithAIO_DEFAULT_USERunset, that is the daemon's own account. - A missing account answers
400 no such user: alice. A non-root daemon cannot switch owners and answers400 cannot run as alice: aiod is running as uid 501 and only root can change identity; it never writes silently under the wrong owner. - Linux only. Here
useris an ownership identity; commands and terminals useuseras an execution identity instead. See Commands (Bash).
Patterns for an agent
Most file work is a short sequence: locate, read the part that matters, edit in place, and let another plane run the result. Writing a file and then running a command over it is two calls, because both planes see the same disk.
File Operations runs the whole story: scaffold a project, find the TODO, edit it, watch the directory while a command changes it, then transfer the result.
Error handling
An expected filesystem failure returns the same structured data on both sides. What differs is the status the call is wrapped in.
The status carries the kind of failure, and data.error_type names it:
A malformed body or query field is 422 with an errors list, whose location starts with body or query.
Every expected filesystem failure is HTTP 200 with success: false, so the status says nothing: check success, then data.error_type. A malformed body or query field is the exception, and is 422 with an errors list.
data describes the failure:
errno,errno_name— the OS error number and its symbolic name, such asENOENTerror_type— the kind of failure:not_found,permission_denied,already_exists, …exception_type— the matching exception class name, such asFileNotFoundErrormessage,operation,path— the OS message, the operation that failed, and the path involvedretryable— whether a retry can succeed
download is the exception on both sides: it streams bytes on success and answers 404 for a missing path. Watch routes keep the envelope but use the status for lifecycle failures: 404 for an unknown watcher, 400 for a debounce or limit outside its bounds, 429 at the watcher limit, 503 for a wait that timed out.
Related
- Error Handling — the envelope, the status conventions, and the other planes
- Commands (Bash) — the plane that sees the files this one writes
- File Operations — the whole surface in one runnable example