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

# Volumes

> Upload a tarball once and attach it to one or many Declaw sandboxes at create time.

```python theme={null}
from declaw import Sandbox, Volumes, VolumeAttachment
```

A **volume** is a tenant-owned blob (gzip-compressed tar archive) that lives in Declaw's object store. You upload a volume once with `Volumes.create(...)` and attach it to any number of sandboxes at create time via `Sandbox.create(volumes=[...])`. On boot, Declaw streams the blob from object storage and materializes its regular-file entries under the attachment's `mount_path` before the first command runs.

Volumes are the right fit when you want to:

* Re-use the same dataset, model weights, or reference code across many short-lived sandboxes without re-uploading bytes each time
* Stage data before the sandbox exists (CI pipelines, fanout workloads)
* Let multiple parallel sandboxes read the same files without a per-sandbox upload step

## How volumes work

* **Format:** only gzip-compressed tar archives (`application/gzip`). Any file-type metadata in the tar is honored; symlinks, hardlinks, device nodes, and entries containing `..` are dropped for safety.
* **Size:** the upload body is capped at 4 GiB; a file-granular volume has a flat **64 GiB** capacity cap.
* **Semantics:** read-at-boot. A volume is materialized into each sandbox's overlay filesystem when it attaches. Writes inside the sandbox are private to that sandbox and never flow back to the volume.
* **Ownership:** a volume is strictly owner-scoped. You can attach only your own volumes.

## `Volumes.create()`

Upload a tar.gz and register it.

```python theme={null}
# From a bytes object
with open("dataset.tar.gz", "rb") as f:
    vol = Volumes.create(name="training-set-v1", data=f)

# From a filesystem path — directory or file is tarred in-memory
vol = Volumes.create(name="training-set-v1", data="/path/to/dir")

# From raw bytes already in memory
vol = Volumes.create(name="training-set-v1", data=raw_bytes)

print(vol.volume_id, vol.size_bytes)
```

<ParamField body="name" type="str" required>
  Human-readable name. Not used for addressing — the server returns a stable `volume_id`.
</ParamField>

<ParamField body="data" type="bytes | BinaryIO | Iterable[bytes] | str | PathLike" required>
  The blob body. `bytes`, a file-like object open in binary mode, and iterables of byte chunks are streamed as-is. A path-like pointing at a file or directory is tarred and gzipped in-memory (convenience for small trees; pre-build the archive for large ones).
</ParamField>

<ParamField body="content_type" type="str" default="'application/gzip'">
  Content-Type header sent with the upload. Leave as the default.
</ParamField>

<ParamField body="api_key" type="str | None">
  Override the API key from environment.
</ParamField>

<ParamField body="domain" type="str | None">
  Override the API domain (e.g. `api.declaw.ai`).
</ParamField>

<ParamField body="request_timeout" type="float | None">
  Per-request timeout in seconds. Bump this for multi-GiB uploads (default `httpx` timeout is 30s).
</ParamField>

**Returns:** a `Volume` with `volume_id`, `owner_id`, `name`, `blob_key`, `size_bytes`, `content_type`, `metadata`, and `created_at`.

## `Volumes.list()`

List all volumes owned by the caller, newest first.

```python theme={null}
for vol in Volumes.list():
    print(vol.volume_id, vol.name, vol.size_bytes)
```

## `Volumes.get()`

Fetch metadata for a single volume.

```python theme={null}
vol = Volumes.get("vol-abc123")
```

Raises `NotFoundException` if the volume does not exist or is owned by a different tenant.

## `Volumes.delete()`

Delete the blob and the metadata row.

```python theme={null}
Volumes.delete("vol-abc123")
```

Idempotent on a 404 — callers that don't care about "already gone" can ignore the exception.

## Attaching to a sandbox

Pass `volumes=[...]` to `Sandbox.create()`:

```python theme={null}
from declaw import Sandbox, VolumeAttachment

vol = Volumes.create(name="dataset", data="/path/to/dir")

sbx = Sandbox.create(
    template="python",
    timeout=600,
    volumes=[VolumeAttachment(volume_id=vol.volume_id, mount_path="/data")],
)

# The files are already visible by the time the first command runs
print(sbx.commands.run("ls -la /data").stdout)
```

<ParamField body="volumes" type="list[VolumeAttachment] | list[dict]">
  One or more attachments. Each is either a `VolumeAttachment(volume_id, mount_path)` dataclass or a plain `{"volume_id": ..., "mount_path": ...}` dict. `mount_path` must be an absolute path and must not target a system directory (`/`, `/etc`, `/usr`, `/proc`, `/sys`, `/dev`, `/bin`, `/sbin`, `/lib`, `/lib64`, `/var`, `/run`, `/boot`).
</ParamField>

The same `volume_id` can appear in many sandbox-create calls in parallel; each sandbox gets its own materialized copy on its overlay.

## File-granular volumes (live mounts)

The volumes above are **copy-mode**: a tar.gz hydrated into the sandbox at boot, with writes private to each sandbox. A **file-granular** volume is different — you can edit its files directly from the SDK (no sandbox), and **live-mount** it into a sandbox so reads *and* writes go straight to the shared volume.

|                          | Copy (`Volumes.create`)  | File-granular (`Volumes.empty` / `ingest`) |
| ------------------------ | ------------------------ | ------------------------------------------ |
| Created from             | a tar.gz blob            | empty, or a tar.gz (`ingest`)              |
| Edit without a sandbox   | no                       | yes — the `files` API                      |
| Attach mode              | `copy` (hydrate at boot) | `copy`, `mount` (rw), or `mount-ro`        |
| Sandbox writes flow back | no (private copy)        | yes (live mount)                           |

### Create a file-granular volume

```python theme={null}
vol = Volumes.empty(name="scratch")              # empty
vol = Volumes.ingest(name="seed", data="/dir")   # or pre-populated from a tar.gz
print(vol.backend)   # "juicefs" / "local" (not "tarball")
```

### Edit files without a sandbox — `Volumes.files()`

```python theme={null}
files = Volumes.files(vol.volume_id)
files.write("/config/app.json", b'{"k": "v"}')   # parent dirs auto-created
files.mkdir("/data")
print(files.read("/config/app.json"))            # b'{"k": "v"}'
for e in files.list("/"):
    print(e.path, e.is_dir, e.size)
files.rename("/config/app.json", "/config/app.prod.json")
files.remove("/data", recursive=True)
```

`files.info(path)` returns a `version` token; pass it to `write(..., if_version=...)` for an optimistic compare-and-set write — a `ConflictException` (409) means the file changed underneath you.

### Live-mount into a sandbox

```python theme={null}
files = Volumes.files(vol.volume_id)
files.write("/greeting.txt", b"hello from the files API")

sbx = Sandbox.create(
    template="base",
    volumes=[VolumeAttachment(volume_id=vol.volume_id, mount_path="/data", mode="mount")],
)
# The sandbox reads the files-API write over a live NFS mount...
print(sbx.commands.run("cat /data/greeting.txt").stdout)
# ...and its writes are visible back through the files API immediately:
sbx.commands.run("echo 'from the sandbox' > /data/out.txt")
print(files.read("/out.txt"))   # b'from the sandbox\n'
```

Use `mode="mount-ro"` for a read-only mount — guest writes are rejected with a read-only-filesystem error. Live mounts require a file-granular volume; copy-mode volumes can only be attached with `mode="copy"`.

### Mount a sub-path

Mount just part of a volume with `subpath` (live-mount only — the server rejects `subpath` on a `copy` attachment):

```python theme={null}
sbx = Sandbox.create(
    template="base",
    volumes=[VolumeAttachment(
        volume_id=vol.volume_id,
        mount_path="/data",
        mode="mount",
        subpath="datasets/train",   # mounts <volume>/datasets/train at /data
    )],
)
```

## Snapshot a sandbox's files into a volume

Capture filesystem state from a running sandbox into a **new** volume — the source is never modified:

```python theme={null}
# Any absolute in-sandbox path -> new volume
vol = Volumes.snapshot(sbx.sandbox_id, path="/workspace/out", name="run-42")

# An already-attached volume's mount path -> new volume
vol = Volumes.commit(sbx.sandbox_id, volume_id=src.volume_id, name="checkpoint")
```

`snapshot` captures *any* in-sandbox path; `commit` captures the mount path of a volume already attached to that sandbox. Both return a new `Volume`; `name` is optional (the server defaults it). Synthetic paths (`/proc`, `/sys`, `/dev`) are rejected.

## Advisory locks

Coordinate writers to a shared (live-mounted) volume with advisory **leases** over a `(volume, path)` pair. `acquire` returns a token you must present to `renew` / `release`:

```python theme={null}
locks = Volumes.locks(vol.volume_id)

lease = locks.acquire("/data/model.bin", ttl_seconds=60)   # ConflictException (409) if already held
token = lease["token"]

locks.renew("/data/model.bin", token, ttl_seconds=60)
print(locks.status("/data/model.bin"))                     # {"held": True, "expires_in_ms": ...}
locks.release("/data/model.bin", token)                    # True if released
```

Locks are **advisory** — they coordinate cooperating writers; they don't block I/O from code that ignores them.

## Async

Every call has an awaitable mirror:

```python theme={null}
from declaw import AsyncSandbox, AsyncVolumes, VolumeAttachment

vol = await AsyncVolumes.create(name="dataset", data=open("dataset.tar.gz", "rb"))
sbx = await AsyncSandbox.create(
    template="python",
    volumes=[VolumeAttachment(volume_id=vol.volume_id, mount_path="/data")],
)
```

## What `Volume` looks like

```python theme={null}
@dataclass
class Volume:
    volume_id: str        # "vol-..."
    owner_id: str
    name: str             # human-readable, supplied at create
    blob_key: str         # object-store path, for reference
    size_bytes: int
    content_type: str     # "application/gzip"
    created_at: str
    metadata: dict[str, str]

    def attach(self, mount_path: str) -> VolumeAttachment:
        ...
```

## Errors

| Situation                                                      | Exception                  | HTTP |
| -------------------------------------------------------------- | -------------------------- | ---- |
| Volume not found or not owned by caller                        | `NotFoundException`        | 404  |
| `mount_path` is a system directory or relative                 | `InvalidArgumentException` | 400  |
| Referenced `volume_id` doesn't belong to caller at attach time | `AuthenticationException`  | 403  |
| Upload body exceeds 4 GiB                                      | `InvalidArgumentException` | 413  |
