ctx.files is how an automation reads and writes files. It never imports node:fs itself. The facade is the platform's file surface, so the jail, the tracing and the binary cap stay a platform concern behind a small API (framework/file-types.ts).
#The jail
Every path an automation passes is relative to the Robot's data directory. That keeps an automation portable: the same source runs under whatever TS_DATA_DIR the Robot was given, with no machine path baked in.
Absolute paths, drive letters, .. traversal and symlink escapes are all refused with FILES_PATH_REJECTED, a SystemException with retryable: false. The check normalises Windows separators first, so ..\x is rejected on every operating system rather than only on Windows. The one deliberate exception is exists: a probe reads an escape attempt as absent, so a guard clause like ctx.files.exists('../secret.txt') is false rather than throwing.
#Reading and writing text
The facade carries twelve verbs (framework/file-types.ts):
exists(file)returns a boolean.readFile(file)reads utf-8 text.writeFile(file, data)andappendFile(file, data)write utf-8; parent directories are created.list(dir?)lists the direct children of a directory, defaulting to the data root.deleteFile(file)removes a file and is a no-op when it is missing.readJson(file)andwriteJson(file, value)round-trip native JSON values.readCsv(file)returns{ headers, rows }with rows keyed by header;writeCsv(file, headers, rows)escapes commas, quotes and newlines.readBinary(file)andwriteBinary(file, data)handle bytes.
A ledger written by an automation looks like this:
import type { RunContext, Transaction } from '../framework';
const LEDGER = 'out/ledger.csv';
const HEADERS = ['CustomerId', 'Amount'];
async function record(ctx: RunContext, tx: Transaction): Promise<void> {
const row = { CustomerId: tx.id, Amount: String(tx.payload.Amount ?? '') };
if (!ctx.files.exists(LEDGER)) await ctx.files.appendFile(LEDGER, HEADERS.join(',') + '\n');
await ctx.files.appendFile(LEDGER, [row.CustomerId, row.Amount].join(',') + '\n');
ctx.log.info(`Appended ${tx.id}`);
}
async function readSeen(ctx: RunContext): Promise<Set<string>> {
if (!ctx.files.exists(LEDGER)) return new Set();
const { rows } = await ctx.files.readCsv(LEDGER);
return new Set(rows.map((r) => String(r.CustomerId ?? '').trim()));
}
CSV values always come back as strings, because a CSV cell has no type. readJson keeps the JSON type, so a number stays a number.
#Reading and writing bytes
readBinary returns a Uint8Array, declared that way so no Node type leaks into an automation. Use it for an attachment, an archive, an image or a downloaded report. A text write would mangle any non-UTF-8 byte, which is why reading an attachment as text and re-encoding it corrupts the attachment.
Both binary verbs enforce a 32 MiB cap (MAX_BINARY_BYTES). On the read side the size is checked from stat before the read, because a cap enforced after the allocation prevents nothing. Exceeding it refuses with FILE_TOO_LARGE and writes nothing.
#What state it is in
The capability audit measured files as WORKING: a 256-byte binary written, read back and hashed, with the two hashes equal. The binary verbs were added later by the binary-file-api slice, which also gave the email attachment path byte-exact reads.
#Things that catch people out
There is no rename, move or copy verb. To move a file you read its bytes and write them to the new name, then delete the original.
Declare 'files'. A call to ctx.files in an automation whose capabilities array omits it throws FILES_NOT_DECLARED.
exists returns false for an escape attempt rather than throwing, so a path check is not a jail check. The jail rejects the read itself.
deleteFile is a no-op when the file is missing, so a run that deletes at the top does not need a guard.