Skip to content

Read and write files from code

urai:fs is the uraijs library for the thread filesystem. Import fs and call its methods. All methods that touch a file are async.

import { fs } from "urai:fs";
// First script: save the result.
await fs.writeJSON("/work/customers.json", customers);
// A later script, in the same turn or in a later turn:
const saved = await fs.readJSON("/work/customers.json");

The library asks for no API key and no configuration. Urai attaches the thread’s workspace to the execution before the script starts.

Not every execution has a filesystem. Call fs.available() one time at the start of the script, and do not catch an error on each call. “No filesystem here” is a different condition from “this write failed”.

import { fs } from "urai:fs";
if (!fs.available()) {
throw new Error("This run has no thread filesystem.");
}

This check is most useful in a library, because the same library can run in an agent script, which has a filesystem, and in a tool or a workflow, which does not. When no filesystem is attached, every file method throws NoWorkspace.

fs.workspace() returns the id of the attached workspace, which is the thread id.

  • A path starts at the root of the thread’s workspace. /out/report.md and out/report.md are the same file.
  • A path cannot contain ... The library refuses it.
  • Each part of a path can have at most 255 bytes.

There is no current directory and no way to reach the host’s filesystem.

await fs.writeText("/out/summary.md", markdown);
await fs.writeJSON("/work/rows.json", rows);
await fs.write("/out/logo.png", pngBytes); // Uint8Array

A write creates the parent folders, and it replaces the file if the file exists. writeJSON writes indented JSON, so a person can read the file too.

To add to a file, append:

await fs.appendLine("/work/progress.log", `fetched page ${page}`);
await fs.append("/work/export.csv", csvChunk);

appendLine adds the newline when the line has none. append takes a string or a Uint8Array. Both create the file when it does not exist.

const text = await fs.readText("/uploads/notes.txt");
const rows = await fs.readJSON<Row[]>("/work/rows.json");
const bytes = await fs.read("/uploads/photo.jpg"); // Uint8Array

readText decodes UTF-8 and replaces invalid bytes with U+FFFD. When a file is not valid JSON, the readJSON error names the file.

Each read loads the whole file into memory. An agent script has a limited heap, so read a large file only when the script needs all of it. Use fs.decode(bytes) to decode a Uint8Array that you already have.

Reading a file that does not exist throws an error. Use fs.exists() when a missing file is a normal result:

const seen = (await fs.exists("/work/seen.json"))
? await fs.readJSON<string[]>("/work/seen.json")
: [];

list returns the direct children of a folder, sorted by name:

for (const entry of await fs.list("/uploads")) {
console.log(entry.name, entry.kind); // "sales.csv" "file"
}

glob searches the whole workspace with a gitignore-style pattern. It returns files only, not folders:

const csvs = await fs.glob("/uploads/*.csv");
const allJson = await fs.glob("**/*.json");
for (const hit of csvs) {
console.log(hit.path, hit.size, new Date(hit.mtime));
}

A pattern can start with / or not. stat returns the size, the kind, the modification time, and the mode of one path.

await fs.remove("/work/page-1.html");
await fs.remove("/work/pages", { recursive: true });

remove without recursive refuses a folder. Nothing removes the root folder. mkdir creates a folder and its parents. A write creates the parents too, so you seldom need mkdir.

The thread has a limit on total size, on the size of one file, and on the number of files. The limits are shared with the user’s uploads.

Urai checks the limit before it writes. When a write goes over, it throws QuotaExceeded, writes nothing, and leaves the file as it was. Catch that error when the script can do something useful, such as write less:

import { fs, QuotaExceeded } from "urai:fs";
try {
await fs.writeJSON("/out/full-export.json", rows);
} catch (e) {
if (!(e instanceof QuotaExceeded)) throw e;
await fs.writeJSON("/out/export-first-1000.json", rows.slice(0, 1000));
}

fs.usage() returns the current usage and the limits:

const u = fs.usage();
// { bytes, files, maxBytes, maxFileBytes, maxFiles }
if (u && u.bytes + estimate > u.maxBytes) {
// write less
}

usage() returns null when no filesystem is attached.

Urai copies writes to durable storage all the time, and it saves everything when the script ends. If the host fails during a script, the most recent writes can be lost.

Call fs.flush() after the script makes something that you must keep, for example before a long step that follows:

await fs.writeJSON("/out/results.json", results);
await fs.flush();

flush also confirms that this execution still owns the workspace. One call is enough. Do not call it after every write.

Error Cause What to do
NoWorkspace No filesystem is attached to this execution. Check fs.available() at the start.
QuotaExceeded The write goes over a limit. Nothing was written. Write less, or remove files you do not need.
WorkspaceLost Another process now owns the workspace, and Urai is stopping this execution. Do not retry.
Error Any other failure, for example a missing file, a path with .., or remove on a folder without recursive. Read the message.

Import the error classes from urai:fs and test them with instanceof.

Put these rules in the agent’s instructions or in a skill when the defaults are not enough:

  • Return paths, not contents. await urai.complete({ report: "/out/report.md" }) keeps the file out of the model’s context.
  • Use clear names. The user sees the file names in Browse files.
  • Never write a secret to a file. The file stays after the execution ends.
  • Put files for the user in /out/ and intermediate files in /work/.

Workflow steps have no filesystem at this time. fs.available() returns false in a workflow, and every file method throws NoWorkspace.

uraijs eval attaches a local workspace, so a script that uses urai:fs runs on your machine without changes:

Terminal window
uraijs eval write.ts
uraijs eval read.ts

The files stay between commands. The second script reads what the first one wrote. The CLI keeps the workspace in .urai/workspace/ in the working directory. uraijs init adds .urai/ to .gitignore.

Task Command
Use a different workspace uraijs eval script.ts --workspace charts
Use a different working directory uraijs eval script.ts -d ./project
Start again with no files rm -rf .urai/workspace

The local workspace has the same limits as a thread, so a script that stays inside the limits on your machine also stays inside them in a conversation. Only uraijs eval attaches a workspace. uraijs call, uraijs dev, uraijs test, and uraijs workflow run do not.

To make a folder look like a thread, put test files in /uploads/ first:

seed.ts
import { fs } from "urai:fs";
await fs.writeText(
"/uploads/sales.csv",
"region,amount\nnorth,1200\nsouth,950\n",
);
await urai.complete({ seeded: "/uploads/sales.csv" });