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

# Storage

> Object storage on a fullstack app: buckets, objects, signed URLs.

A `fullstack` [app](/docs/api/apps) has an object store alongside its database: **buckets** you name, holding **objects** by key. Storage is app-scoped and org-level — it belongs to the app, outlives any session, and is reachable by every agent that can reach the app (`allowed_apps` on the `apps` tool). A `frontend_only` app has no storage and answers `501 feature_not_configured`; a backend still provisioning answers a retryable `job_not_ready`.

This is not the [files](/docs/api/files) API. Files are what your agents produce; storage is what your app serves.

`{id}` in every route below is what the [database](/docs/api/database) routes take: an `app_` id, the app's **name**, or the word **`default`**, which resolves the organization's only `fullstack` app. With two or more `fullstack` apps `default` is `409 ambiguous_app`; with none, `404 not_found`.

## Shapes

<ResponseField name="bucket" type="object">`{ object: "bucket", name, public, created_at }`. A name is `^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$`. `public` is recorded on the bucket; every read through Vetta is authorized by your key or a signed URL either way.</ResponseField>
<ResponseField name="storage_object" type="object">`{ object: "storage_object", bucket, key, size_bytes, content_type, etag, updated_at }`. `etag` is the store's hash without quotes, or `null` when it reports none.</ResponseField>
<ResponseField name="signed_url" type="object">`{ object: "signed_url", url, method, expires_at }`. `method` is `PUT` or `GET`. The `url` points at the app's own backend host and is the only place that host is visible; it carries its grant in the URL and is dead after `expires_at`.</ResponseField>

## Buckets

* `GET /v1/apps/{id}/storage/buckets` — scope `agents:read`. `{ object: "list", data: bucket[] }` — the buckets the app's store holds, including ones your own code created there. A bucket whose name falls outside the grammar above is the store's alone: it is neither listed here nor tracked by Vetta.
* `POST /v1/apps/{id}/storage/buckets` — scope `agents:write`. Body `{ name, public? }` (default private) → `201 bucket`. An existing name is `409 name_conflict`.
* `DELETE /v1/apps/{id}/storage/buckets/{bucket}` — scope `agents:write`. A non-empty bucket is refused with `400 validation_failed` (param `force`); `?force=true` empties it first.

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -fsSL https://api.vetta.sh/v1/apps/app_01H.../storage/buckets \
    -H "authorization: Bearer sk_live_..." \
    -H "content-type: application/json" \
    -d '{ "name": "avatars", "public": true }'
  ```

  ```typescript TypeScript theme={"system"}
  await vetta.storage.buckets.create("avatars", { public: true });
  ```
</CodeGroup>

## Objects

* `GET /v1/apps/{id}/storage/buckets/{bucket}/objects?prefix=&limit=&after=` — scope `agents:read`. A [page](/docs/api/pagination) of `storage_object`, keys under `prefix`.
* `PUT /v1/apps/{id}/storage/buckets/{bucket}/objects/{key}` — scope `agents:write`. The raw body is the object, up to 50 MiB; `Content-Type` is required and stored → `storage_object`. Anything larger goes through an [upload URL](#signed-urls).
* `GET /v1/apps/{id}/storage/buckets/{bucket}/objects/{key}` — scope `agents:read`. The bytes, with the stored `Content-Type` — authorized by your key like every other read. `?signed=true` returns a one-hour `signed_url` (GET) instead of the bytes.
* `DELETE /v1/apps/{id}/storage/buckets/{bucket}/objects/{key}` — scope `agents:write`. Idempotent `204`.

A key may contain `/` — `users/alice.json` is one object with a two-segment key. Encode each segment as you would any path segment; an empty segment or `..` is `400 validation_failed` (param `key`).

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -fsSL -X PUT https://api.vetta.sh/v1/apps/app_01H.../storage/buckets/avatars/objects/users/alice.json \
    -H "authorization: Bearer sk_live_..." \
    -H "content-type: application/json" \
    --data-binary '{ "name": "Alice" }'
  ```

  ```typescript TypeScript theme={"system"}
  await vetta.storage.upload("avatars", "users/alice.json", { name: "Alice" });
  const { bytes, contentType } = await vetta.storage.download("avatars", "users/alice.json");
  ```
</CodeGroup>

<ResponseExample>
  ```json Response theme={"system"}
  {
    "object": "storage_object",
    "bucket": "avatars",
    "key": "users/alice.json",
    "size_bytes": 18,
    "content_type": "application/json",
    "etag": "9a0364b9e99bb480dd25e1f0284c8555",
    "updated_at": "2026-09-06T18:00:00Z"
  }
  ```
</ResponseExample>

## Signed URLs

`POST /v1/apps/{id}/storage/buckets/{bucket}/objects/{key}/upload-url` — scope `agents:write`

<ParamField body="content_type" type="string" required>The type the upload will carry.</ParamField>
<ParamField body="expires_seconds" type="integer">60–3600, default 900.</ParamField>

Returns a `signed_url` with `method: "PUT"`. `PUT` the bytes to `url` with that `Content-Type` before `expires_at`; the object then appears in the bucket like one uploaded inline. The download counterpart is `GET .../objects/{key}?signed=true`, above.

## From an agent

An agent with the [`apps` tool](/docs/capabilities/tools) reaches the same store through five actions, gated by the tool's `allowed_apps`. When the organization has exactly one fullstack app, `app` may be omitted; several is `409 ambiguous_app`, none is `404 not_found`.

| Action               | Arguments                                            | Returns                                                                                                                |
| -------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `storage_list`       | `bucket`, `prefix?`, `after?`                        | `{ objects, has_more, next_cursor }` — pass `next_cursor` back as `after` for the next 100.                            |
| `storage_put`        | `bucket`, `key`, `file_id`                           | The `storage_object` written from an existing `fil_` — its bytes and `content_type` become the object.                 |
| `storage_get`        | `bucket`, `key`                                      | `{ object: storage_object, file_id }` — the bytes land in a **new** [file](/docs/api/files) the agent reads like any other. |
| `storage_remove`     | `bucket`, `key`                                      | `{ removed: { bucket, key } }`.                                                                                        |
| `storage_signed_url` | `bucket`, `key`, `content_type?`, `expires_seconds?` | A `signed_url` (`PUT` with `content_type`, otherwise `GET`) to hand to a browser or a script.                          |

Bytes never sit in a tool result: `storage_put` reads a `fil_` the agent already has and `storage_get` returns one it did not, so the [files](/docs/api/files) API is the way in and out.

## Auditing and billing

Bucket creation and deletion, object uploads, downloads and deletions are [audit](/docs/api/audit-logs) rows (`app.bucket_created`, `app.bucket_deleted`, `app.storage_put`, `app.storage_get`, `app.storage_deleted`) carrying the bucket and key — never the bytes. Creating or deleting a bucket also reaches [webhook](/docs/api/webhooks) subscribers as `app.storage.bucket.created` / `app.storage.bucket.deleted` with `{ app_id, bucket }`, once the bucket and its audit row exist; object reads and writes are audit rows only, never [events](/docs/api/events). Storage bills on the `storage` [line item](/docs/api/credits) per bucket per day once a rate is published; **no rate is published today, so storage carries no charge** — and no line appears on the ledger.

## Errors

| status | code                     | when                                                                                                                           |
| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `400`  | `validation_failed`      | a bad bucket name or key, a missing `Content-Type`, an inline `PUT` over 50 MiB, or a non-empty bucket deleted without `force` |
| `404`  | `not_found`              | no such app, bucket or object                                                                                                  |
| `409`  | `name_conflict`          | the bucket already exists                                                                                                      |
| `409`  | `ambiguous_app`          | `default` and the organization holds several `fullstack` apps; name one                                                        |
| `501`  | `feature_not_configured` | a `frontend_only` app                                                                                                          |
