> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mirage.strukto.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Box

> Mount Box as a MIRAGE VFS from Node or the browser via OAuth2.

Mirage ships `BoxVFS` in **two runtimes**:

* `@struktoai/mirage-node`, uses `client_id` + `client_secret` + refresh token (rotated on each use)
* `@struktoai/mirage-browser`, supports the same refresh token via PKCE (no client secret in the bundle)

Both runtimes hit the same [Box v2 API](https://developer.box.com/reference/) endpoints
(`/folders/{id}/items`, `/files/{id}/content`, `/search`, plus multipart upload, folder
create, delete, rename/move, and copy for writes). Credentials are obtained the same way
in both runtimes, see [Box Credentials](https://docs.mirage.strukto.ai/home/setup/box.md).

Both `READ` and `WRITE` modes are supported (mount with `{ mode: MountMode.WRITE }` to
enable `tee`/`cp`/`mv`/`rm`/`mkdir`/`touch`). Pass `rootFolderId` to mount a single Box
folder as the workspace root instead of the whole account; folder ids are stable across
renames and moves. The Python package ships the same backend, see
[Box (Python)](https://docs.mirage.strukto.ai/python/vfs/box.md).

## Quick start: developer token

For exploration, skip the OAuth flow and use a developer token (one-button-click in the Box
app console, 60-minute lifetime). The `BoxVFS` accepts an `accessToken` field that
short-circuits the refresh logic:

```ts theme={null}
import { BoxVFS, MountMode, Workspace } from '@struktoai/mirage-node'

const box = new BoxVFS({
  accessToken: process.env.BOX_DEVELOPER_TOKEN!,
})
const ws = new Workspace({ '/box': box }, { mode: MountMode.READ })
await ws.shell('ls /box/')
```

When the token expires (Box 401s with `invalid_token`), regenerate it in the console and
re-run. See [Box Credentials -> Quick Start](https://docs.mirage.strukto.ai/home/setup/box.md#quick-start-developer-token-60-minutes-no-oauth).

## Service account (client credentials, no refresh token)

For headless server auth, skip OAuth and refresh tokens entirely. Create a Box app with the
**Server Authentication (Client Credentials Grant)** method, authorize it once in the Box
admin console (**Apps -> Custom Apps Manager**), and pass the enterprise ID:

```ts theme={null}
const box = new BoxVFS({
  clientId: process.env.BOX_CLIENT_ID!,
  clientSecret: process.env.BOX_CLIENT_SECRET!,
  enterpriseId: process.env.BOX_ENTERPRISE_ID!,
})
```

Tokens are minted for the app's **service account** and re-fetched automatically on expiry;
there is no refresh token to rotate or persist.

> [!WARNING]
>
> The service account is a separate Box user with its own (initially empty) root folder. To see
> your content, share folders with the service account's email address, shown under the app's
> **General Settings** in the developer console.

## Node (server-side, long-running)

```bash theme={null}
pnpm add @struktoai/mirage-node
```

```ts theme={null}
import fs from 'node:fs/promises'
import { BoxVFS, MountMode, Workspace } from '@struktoai/mirage-node'

const box = new BoxVFS({
  clientId: process.env.BOX_CLIENT_ID!,
  clientSecret: process.env.BOX_CLIENT_SECRET!,
  refreshToken: process.env.BOX_REFRESH_TOKEN!,
  // Box rotates the refresh token; persist the new one if you want to survive restarts.
  onRefreshTokenRotated: async (next) => {
    await fs.writeFile('.box-refresh', next, 'utf-8')
  },
})

const ws = new Workspace({ '/box': box }, { mode: MountMode.READ })
const res = await ws.shell('ls /box/')
console.log(res.stdoutText)
```

The `BoxTokenManager` caches the access token in memory (5-minute safety buffer before expiry)
and rotates the refresh token on every refresh. Without `onRefreshTokenRotated`, the rotation
is in-memory only and a process restart needs a fresh refresh token.

### Config

| Field | Description |
| - | - |
| `timeout` default `DEFAULT_TIMEOUT` | Seconds a request may wait to connect, or between two reads of its answer, before it fails; a download that keeps flowing is never cut off. |
| `endpoint` | Base URL that replaces Box's API, upload and OAuth token hosts, such as a test server. Unset uses Box's hosts. |
| `rootFolderId` default `'0'` | Box folder ID the mount uses as its root, from the folder's web URL (`box.com/folder/<id>`); unlike a path it survives renames and moves. `0` is the account root. |
| `clientId` | Box app client ID. Required with `refreshToken` (OAuth) or with `clientSecret` and `enterpriseId` (client credentials). |
| `clientSecret` secret | Box app client secret. Required for client credentials; the OAuth refresh sends it when set, so a PKCE client can omit it. |
| `refreshToken` secret | OAuth refresh token; needs `clientId`. Box rotates it on every refresh, so persist the new one with `onRefreshTokenRotated`. |
| `enterpriseId` | Box enterprise ID for the client-credentials grant. With `clientId` and `clientSecret`, tokens are minted for the app's service account and no refresh token is involved. |
| `accessToken` secret | Box developer token, used as is with no refresh flow; it expires after about 60 minutes. It wins over every other credential. Also accepted as `developer_token`. |
| `refreshFn` secret | Function that takes the current refresh token and returns `{ accessToken, refreshToken, expiresIn }`, called instead of Box's token endpoint. Needs `clientId` and `refreshToken`. |
| `onRefreshTokenRotated` secret | Function (sync or async) given the new refresh token whenever a refresh rotates it, to persist it; the token it replaces stops working after its first use. |

Fields marked secret are redacted in snapshots.

## Browser (PKCE, no client secret)

```bash theme={null}
pnpm add @struktoai/mirage-browser
```

```ts theme={null}
import { BoxVFS, MountMode, Workspace } from '@struktoai/mirage-browser'

// Refresh token obtained via the PKCE flow, see examples/browser/vfs/box/box_pkce.ts.
const box = new BoxVFS({
  clientId: import.meta.env.VITE_BOX_CLIENT_ID,
  refreshToken: localStorage.getItem('box-refresh')!,
  onRefreshTokenRotated: (next) => localStorage.setItem('box-refresh', next),
})

const ws = new Workspace({ '/box': box }, { mode: MountMode.READ })
await ws.shell('ls /box/')
```

Box requires the origin to be allowlisted on the app's **Allowed Origins** config (see
[setup](https://docs.mirage.strukto.ai/home/setup/box.md#cors-notes)). The bundled
[`examples/browser/vfs/box/box_pkce.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/browser/vfs/box/box_pkce.ts)
runs the full PKCE dance and persists the rotated refresh token to `localStorage`.

## VFS mode (`patchNodeFs`)

```ts theme={null}
import { createRequire } from 'node:module'
import { BoxVFS, MountMode, patchNodeFs, Workspace } from '@struktoai/mirage-node'

const require = createRequire(import.meta.url)
const fs = require('fs') as typeof import('fs')

const box = new BoxVFS({ accessToken: process.env.BOX_DEVELOPER_TOKEN! })

const ws = new Workspace({ '/box': box }, { mode: MountMode.READ })
const restore = patchNodeFs(ws)

const entries = await fs.promises.readdir('/box/')
const stat = await fs.promises.stat('/box/Documents')
const bytes = await fs.promises.readFile('/box/Documents/example.json')

restore()
```

The async forms (`fs.promises.*` and the callbacks) are routed; a sync form like `fs.statSync` refuses a mounted path.

## FUSE mode

```ts theme={null}
import { BoxVFS, Mount, MountBackend, MountMode, Workspace } from '@struktoai/mirage-node'

const box = new BoxVFS({ accessToken: process.env.BOX_DEVELOPER_TOKEN! })

const ws = new Workspace({ '/box': new Mount(box, { mode: MountMode.READ, backend: MountBackend.FUSE }) })
await ws.fuseReady()
const mp = ws.fuseMountpoint
// The box subtree is exposed at the mountpoint root, so ${mp}/ is a real path:
//   ls ${mp}/
//   find ${mp} -type f
//   cat ${mp}/example.json | jq .
await ws.close()
```

Requires [macFUSE](https://osxfuse.github.io/) on macOS or libfuse on Linux.

## Path → ID resolution

Box uses numeric folder/file IDs internally (root folder is id `0`). Mirage caches the path → ID
mapping in the mount's index store (RAM unless an `index:` is configured). The first time you `ls /box/foo/bar/`,
it walks the tree top-down (one API call per level) and caches each entry's ID. Subsequent reads
of `/box/foo/bar/anyfile.json` hit the cache instead of re-walking.

With no `index:` configured, listings live for the mount's `ttl` (600 s by default), at most 24 hours; a configured `index:` uses its own `ttl`, still capped by the mount's `ttl:`. To force re-resolution, unmount the prefix and mount a new `BoxVFS` there.

## Writes

A write records the `sha1` its upload reply names, so a `fresh` read
right after a `tee` or `sed -i` downloads nothing.

## Conditional writes

A Box mount accepts `write: conditional`. A
write or a file delete compares the `sha1` mirage holds with the one the
folder listing it walks anyway shows, and refuses a file changed since it
was read before sending anything; the request then carries the file's
`etag` as `If-Match`, so Box refuses (412) a change that lands in between.
A conditional write costs no request more than a plain one. `rm -r` walks
the folder file by file, and a `mv` within the mount is Box's own move,
which is not refused when its source changed (a `mv` to another mount is a
copy and a conditioned delete, so there a changed source stays and `mv`
reports the failed removal).

## Reads under `fresh`

A Box mount accepts `read: fresh`. The token is the file's `sha1`, which a
folder listing and `GET /files/{id}` both carry; a download names no version,
so a whole read stamps the listed `sha1` only when the downloaded bytes hash
to it, and a ranged read stamps nothing. On a
`write: conditional` mount a whole read stamps
the `sha1` of the bytes it downloaded, so a listing another client has since
outdated cannot leave the next write without the version the agent read. `modified_at` is never the token:
two same-size edits in one second share it.

Checking a cached file costs one request, `GET /files/{id}` by the id the
mount last listed, while the mount still holds that file's listing row. The
answer is trusted only when Box places the same active file at the same path
from the mount root; a file that was moved, renamed, trashed or recreated
falls back to listing each folder from the mount root, one listing per level
(up to two per level when the file is gone). When that fallback confirms
byte-identical content, the built-in RAM and Redis indexes retain the newly
resolved file row if the original hint is still current. A later check then
uses the recovered id without refreshing the parent listing. Custom indexes
must implement `replaceIfUnchanged` to retain this row; otherwise later checks
repeat the fallback walk. A persistent 403
still requires the failed point request and a listing walk on each check.
Any stale, unknown or gone verdict on the mount
drops its cached listings, so the checks after one walk until a cold read or
an `ls` lists the folders again. A file Box reports without a `sha1` cannot
be verified: each fresh read of it downloads it again and drops the mount's
cached listings.

A folder's `etag` does not move when its children change, so under `fresh`
listings are re-fetched once per command. Repeated uses within that command
share the result; outside commands, the existing one-second listing trust
window applies. Before fetching a nested listing, mirage resolves its folder through current
parent listings up to the configured mount root. A folder moved away and
replaced under the same name is therefore resolved to the replacement on
the next fresh command. This costs one listing per ancestor level; a warm
file check still uses one metadata request.

## Box-native file types

Every Box item is served as its **raw bytes**, keeping its real name (no `.json` suffix).
Box has no API to edit these formats from a structured payload, so Mirage does not render
them, it hands back exactly what Box stores:

* `.boxnote` / `.boxcanvas`, Box's native Notes and Canvas, stored as ProseMirror-style
  JSON. They are text, so `cat foo.boxnote | jq .` works if you want to poke at the structure.
* `.gdoc` / `.gsheet` / `.gslides`, Box's V2 Google-format files, stored as Office Open XML
  zips (`.docx` / `.xlsx` / `.pptx`). These are opaque binary, like any other Office document
  on a Mirage mount.

For a rich, editable view of Google-format documents, mount them through
[Google Drive](https://docs.mirage.strukto.ai/home/setup/google.md) with Google credentials instead, the `gws docs ...`
commands operate on Google file IDs and do not apply to Box.

## VFS.md

The [VFS.md](https://docs.mirage.strukto.ai/home/setup/vfs-md.md) an agent reads with Box mounted at `/<path>`.

**Rendered**

### Virtual Filesystem

| Path | Description |
| - | - |
| `/<path>` | `box`, write |

#### `/<path>` layout

```text theme={null}
/<path>
  Mirrors Box folder hierarchy. Every item is served as its raw bytes:
    <name>.boxnote      Box Note    (raw ProseMirror-style JSON; pipe to jq)
    <name>.boxcanvas    Box Canvas  (raw canvas JSON; pipe to jq)
    <name>.gdoc/.gsheet/.gslides  Box's Google-Workspace files, stored as
                        Office Open XML (docx/xlsx/pptx) - opaque binary
    <other-files>       PDFs, images, binaries, etc. - raw bytes

  IMPORTANT: This is a remote mount. Prefer targeted reads over full scans.
  Box uses numeric folder IDs internally (root = 0); mirage caches the
  path -> id mapping, so nested dirs cost one API call per level on first
  access. Use ls on the parent dir before constructing a path.
```

#### Supported Unix Commands for VFS

Unix commands work on each path as on a local filesystem, as far as the path supports them:

| Path | Supported |
| - | - |
| `/<path>` | Almost all Unix commands. `grep -r` and `rg` with `-w` ask `box` which files may match. |

#### Limitations of Unix Commands for VFS

Where a path differs from a local filesystem:

| Path | Limitations |
| - | - |
| `/<path>` | Without `-w`, `grep -r` and `rg` read every file. `grep -r`, `find` and `du` stop after 1000 files; a glob matching more, `tar -c`, `cp -r` and `rm -r` are refused. |

Run `man` to list every command, and `man <cmd>` or `<cmd> --help` for one command's usage.

**Raw**

````markdown theme={null}
---
name: workspace-filesystem
description: The virtual filesystem layout of this workspace.
---

# Virtual Filesystem

| Path | Description |
| --- | --- |
| `/<path>` | `box`, write |

## `/<path>` layout

```text
/<path>
  Mirrors Box folder hierarchy. Every item is served as its raw bytes:
    <name>.boxnote      Box Note    (raw ProseMirror-style JSON; pipe to jq)
    <name>.boxcanvas    Box Canvas  (raw canvas JSON; pipe to jq)
    <name>.gdoc/.gsheet/.gslides  Box's Google-Workspace files, stored as
                        Office Open XML (docx/xlsx/pptx) - opaque binary
    <other-files>       PDFs, images, binaries, etc. - raw bytes

  IMPORTANT: This is a remote mount. Prefer targeted reads over full scans.
  Box uses numeric folder IDs internally (root = 0); mirage caches the
  path -> id mapping, so nested dirs cost one API call per level on first
  access. Use ls on the parent dir before constructing a path.
```

## Supported Unix Commands for VFS

Unix commands work on each path as on a local filesystem, as far as the path supports them:

| Path | Supported |
| --- | --- |
| `/<path>` | Almost all Unix commands. `grep -r` and `rg` with `-w` ask `box` which files may match. |

## Limitations of Unix Commands for VFS

Where a path differs from a local filesystem:

| Path | Limitations |
| --- | --- |
| `/<path>` | Without `-w`, `grep -r` and `rg` read every file. `grep -r`, `find` and `du` stop after 1000 files; a glob matching more, `tar -c`, `cp -r` and `rm -r` are refused. |

Run `man` to list every command, and `man <cmd>` or `<cmd> --help` for one command's usage.
````

## Integration

What Box answers through its own API, so fewer files are read. [Integration](https://docs.mirage.strukto.ai/home/integration/overview.md) explains each command.

| Command | On Box |
| - | - |
| `grep -r`, `rg` | Box search, for whole words. It can miss Office files, PDFs, archives, the end of large files, words Box splits differently (`foo.bar`) and recent writes. |
| `sha1sum` | The SHA-1 Box keeps, asked live |

The [Python Box docs](https://docs.mirage.strukto.ai/python/vfs/box.md#integration) list when each search answers and what it can miss.

## Available commands

`BoxVFS` ships the same shell command set as `GoogleDriveVFS` and `DropboxVFS`:

* **Filesystem**: `ls`, `cat`, `head`, `tail`, `nl`, `wc`, `stat`, `find`, `tree`, `du`, `file`,
  `realpath`, `basename`, `dirname`
* **Writes** (in `WRITE` mode): `tee`, `cp`, `mv`, `rm` (`-r`), `mkdir` (`-p`), `touch`,
  `truncate`
* **Search/text**: `grep`, `rg`, `awk`, `sed`, `sort`, `uniq`, `cut`, `diff`, `cmp`, `jq`
* **Encoding**: `base64`

## Examples

End-to-end runnable scripts are in
[`examples/typescript/vfs/box/`](https://github.com/strukto-ai/mirage/tree/main/examples/typescript/vfs/box):

* [`box.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/vfs/box/box.ts), `Workspace.shell('ls/stat/cat/tree …')` shell demo
* [`box_vfs.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/vfs/box/box_vfs.ts), `patchNodeFs` + native `fs.promises.*` calls
* [`box_fuse.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/vfs/box/box_fuse.ts), FUSE mount with shell access in another terminal


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.