Skip to main content
A fullstack app 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 API. Files are what your agents produce; storage is what your app serves. {id} in every route below is what the 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

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.
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.
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.

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.

Objects

  • GET /v1/apps/{id}/storage/buckets/{bucket}/objects?prefix=&limit=&after= — scope agents:read. A page 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.
  • 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).

Signed URLs

POST /v1/apps/{id}/storage/buckets/{bucket}/objects/{key}/upload-url — scope agents:write
string
required
The type the upload will carry.
integer
60–3600, default 900.
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 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. 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 API is the way in and out.

Auditing and billing

Bucket creation and deletion, object uploads, downloads and deletions are audit 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 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. Storage bills on the storage line item 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