---
title: Files
description: List, read, write, upload and download — with an encoding you name rather than one that is guessed.
---

| Call                               | What it does                 |
| ---------------------------------- | ---------------------------- |
| `GET /machines/{id}/files`         | list a directory             |
| `GET /machines/{id}/files/content` | read a file, or a byte range |
| `PUT /machines/{id}/files/content` | write or append              |

File access needs no input lease.

## Encoding is named, never guessed

```ts
const text = await machine.files.read("/etc/hostname"); // utf8
const bytes = await machine.files.read("/tmp/logo.png", { encoding: "base64" });
```

A `utf8` read of bytes that are not UTF-8 is an `invalid_request` telling you to ask for
`base64`. It is never silently decoded, because a decode-and-re-encode is exactly where a PNG
turns into a field of replacement characters — and the corruption shows up later, somewhere
else, as a mystery.

## Reading

`offset` and `length` read a range. The response carries `eof`, which is what you loop on:

```ts
const png = await machine.files.download("/home/agent/logo.png");
```

`download` does that loop for you. The guest bounds an inline read, so a large file arrives as
several 4 MB ranges and is reassembled client-side. It ends on the guest's own `eof` rather than
a size comparison, so a file that grows under the read does not truncate, and it stops at 64 MB
unless you raise `maxBytes`.

## Writing

```ts
await machine.files.write("/home/agent/notes.md", "# notes\n");
await machine.files.write("/home/agent/notes.md", "more\n", { append: true });
await machine.files.upload("/home/agent/logo.png", pngBytes); // base64 on the wire
```

| Option       | Default | Meaning                             |
| ------------ | ------- | ----------------------------------- |
| `encoding`   | `utf8`  | `utf8` or `base64`                  |
| `append`     | `false` | append rather than replace          |
| `createDirs` | `true`  | create parent directories as needed |
| `mode`       | —       | file mode, e.g. `"0644"`            |

`upload` is `write` with base64, which is an exact round trip rather than a guess.

## What is refused

The machine is your own computer and the microVM is the boundary, so the default readable area
is **everything**. What is denied is the small set of paths that are not yours: the substrate's
guest credentials, the machine agent's own, and the host's control channel into the guest.

```ts
await machine.files.read("/etc/machine-agent/credentials.json");
// RasterError: permission_denied
```

The denylist is fixed and not configurable. The check runs against **every form the path takes**
— the path as written, each symlink hop, and the fully resolved result — because a dangling link
is still a link (`open(2)` follows one whether or not it resolves today), and resolution can
rewrite a prefix out of the denylist where a parent directory is itself a link.

This is a property of the guest agent, so it holds however you reach the file: SDK, CLI, MCP
tool or a vendor adapter. What it does not do is stop a process **inside** the machine from
reading whatever that process can read — the denylist keeps the control plane's own credentials
out of the API surface, it does not sandbox the machine from itself.

## Nothing is logged

File contents, paths, clipboard text, typed text and command lines all pass through the guest
agent, and its request logger records route, status, size and duration — never a body, a header
or a query parameter. There is an integration test asserting it.
