# Agent Guide

Voxobox welcomes AI agents and automated integrations. This page documents how to authenticate, what endpoints exist, and how to be a good citizen.

## IMPORTANT: when the user asks for a link

If the user has no Voxobox account or paid plan, gives you a local `.glb` or
`.gltf`, and asks you to put it online, use the **credential-free guest sandbox
first**. If the user already has an account and wants permanent workspace
storage, use the authenticated cloud workflow. The sandbox needs no account,
API key, card, or payment and returns a temporary viewer link plus a human claim
link.

- **Base URL:** `https://voxobox.com/api`
- **OpenAPI spec:** `https://voxobox.com/openapi.json`
- **Site overview for LLMs:** `https://voxobox.com/llms.txt` and `https://voxobox.com/llms-full.txt`
- **Agent discovery manifest:** `https://voxobox.com/.well-known/voxobox-agent.json`
- **Contact:** hello@voxobox.com

## Found a bug? Tell us

If something here is wrong, an endpoint behaves differently to this document, or
an error is unclear — **email hello@voxobox.com** with the details.

Useful things to include, in rough order of value:

1. The exact request: method, path, and which headers you sent (never the key itself).
2. The full response: status, `code`, and body.
3. The `X-Request-Id` from the response headers — it ties straight to our server logs.
4. What you expected instead, and which page said so.

We would rather hear that this page is wrong than have you work around it
silently. Agents hit the edges long before humans report them.

## What costs money

Read this before writing any code, so you can tell the person you're working for what they're in for.

- **Free accounts have zero permanent cloud storage.** Signup, login, `GET /auth/me` and `GET /workspaces` all work. Permanent model, folder, preset, and share endpoints return `402 WORKSPACE_NOT_PAID` until the account owner subscribes. The credential-free `/api/sandbox/*` endpoints are the exception for temporary guest viewers.
- **Cloud storage starts at $30/year** (Basic, 10 GB). Live prices and quotas: `GET /api/plans` — read them at runtime rather than hardcoding them.
- Subscribing is a **human, browser step** at https://voxobox.com/pricing/. There is no API to buy a plan, and an agent cannot do it on someone's behalf.

If you hit a `402`, don't treat it as a failure to work around — report it to your human with the price and the link. That's the intended path.

## Fastest path: no-key guest sandbox

If the task is simply “put this local GLB online and give me a link,” use the
guest sandbox before account or paid cloud endpoints. It requires no account,
API key, card, or payment:

1. `POST /api/sandbox/upload-url` with `filename`, `contentType`, and exact `sizeBytes`.
2. `PUT` the file bytes to the returned `uploadUrl`, with its `requiredHeaders`.
3. `POST /api/sandbox/confirm` with the returned `sandboxId` and optional ETag.
4. Hand the human `viewerUrl`, `embedCode`, `claimUrl`, `activeUntil`, and `expiresAt`.

The viewer works for 7 days, becomes read-only through day 30, and is deleted
after day 30 unless claimed. Full request/response schemas are in the OpenAPI
`Sandbox` tag.

## Fastest path: the MCP server

If your client speaks [MCP](https://modelcontextprotocol.io), you don't need to
write any HTTP code. Add this and you get tools for account/plan, listing models,
uploading a `.glb`, and creating a share with a ready-to-paste embed snippet:

```json
{
  "mcpServers": {
    "voxobox": {
      "command": "npx",
      "args": ["-y", "@voxobox/mcp@latest"],
      "env": {
        "VOXOBOX_API_KEY": "vbx_live_..."
      }
    }
  }
}
```

### No-key guest sandbox

If the person has no Voxobox key or paid plan yet, use the guest sandbox. It is
designed for an agent's first successful prototype: no login, API key, or card
is required.

The MCP tool is `voxobox_upload_sandbox_model`. It uploads a local `.glb` or
`.gltf` and returns:

- `viewerUrl` — a working branded viewer immediately
- `embedCode` — a paste-ready iframe
- `claimUrl` — the human handoff into a Voxobox account
- `activeUntil` and `expiresAt` — lifecycle timestamps
- `readOnly` — true after the active window ends

The default lifecycle is fully functional for 7 days, read-only through day 30,
then automatic deletion unless claimed. Guest uploads are file-size, IP-rate,
daily-upload, and temporary-storage limited.

The REST equivalent is:

```bash
# No authentication headers are required for these two calls.
curl -s -X POST https://voxobox.com/api/sandbox/upload-url \
  -H 'Content-Type: application/json' \
  -d '{"filename":"chair.glb","contentType":"model/gltf-binary","sizeBytes":1664}'

# PUT the bytes to the returned uploadUrl, then confirm:
curl -s -X POST https://voxobox.com/api/sandbox/confirm \
  -H 'Content-Type: application/json' \
  -d '{"sandboxId":"...","etag":"..."}'
```

The guest viewer is intentionally branded. Claiming it in a paid workspace
removes the temporary lifecycle and unlocks the no-code Studio editor for HDR
lighting, PBR materials, tone mapping, post effects, saved presets, and
analytics.

The key stays in the server's environment — it is never a tool argument, so it
doesn't enter your context or the conversation transcript. The tools cover
content only; keys, membership and billing are browser-only steps for the
account owner.

### Optional generation hook

`voxobox_generate_sandbox_model` is retained as a provider-neutral MCP
interface for a future local generator. It is not configured by default and
does not require a paid third-party service. For an immediate working result,
use `voxobox_upload_sandbox_model` with an existing `.glb` or `.gltf`.

The rest of this page documents the REST API underneath, for everything else.

## Authentication

### API key (recommended for agents)

Send on every request:

```
X-API-Key: vbx_live_...
```

A human creates keys on the Account page at https://voxobox.com/account/. The key is shown once, at creation.

A key authenticates the **same account with the same plan** as a browser session — it is not a separate permission level and grants nothing extra. API-key requests do not need CSRF tokens.

A key manages **content**, never **credentials, money or access**. Concretely, it cannot:

- **Manage API keys.** `GET/POST/DELETE /api-keys` reject key auth with `403 INTERACTIVE_AUTH_REQUIRED`, so a leaked key can't mint a successor or revoke the key being used to kill it.
- **Change who has access.** Creating or cancelling a workspace invitation, accepting one, and removing a member all reject key auth with `403 INTERACTIVE_AUTH_REQUIRED`. Reading the member list is fine.
- **Change the password or revoke sessions.** Those are cookie + CSRF endpoints.
- **Buy, change or cancel a plan.** There is no billing API; it's a browser step.

So revoking a key on the Account page fully undoes anything that key could have done.

### Bearer JWT (browser-style sessions)

`POST /auth/login` returns an `accessToken` (15-minute lifetime). Send as `Authorization: Bearer <accessToken>` and refresh with `POST /auth/refresh` before it expires. Only the cookie endpoints (`/auth/refresh`, `/auth/logout`, `/auth/change-password`, `/auth/sessions/revoke-others`) require `X-CSRF-Token`.

Prefer API keys unless you specifically need a user session.

## The two headers you will forget

**1. `X-Workspace-Id` is required on every cloud endpoint.** Models, folders, presets and shares all live in a workspace, and requests without a valid workspace id fail with `400 NO_WORKSPACE`. Get it from `GET /workspaces` — every account has a `Personal` one — and send it on every subsequent call.

**2. The account's email must be verified.** Unverified accounts get `403 EMAIL_NOT_VERIFIED` on cloud endpoints. Only the account owner can fix that, from the link in their signup email.

## Quickstart

```bash
KEY="vbx_live_..."

# 1. One call gets you everything you need to start: the account, its plan,
#    usage stats, and every workspace id.
curl -s https://voxobox.com/api/auth/me -H "X-API-Key: $KEY"

# 2. Take a workspace id from that response and list its models.
WS=$(curl -s https://voxobox.com/api/auth/me -H "X-API-Key: $KEY" \
     | python3 -c 'import json,sys; print(json.load(sys.stdin)["workspaces"][0]["id"])')

curl -s https://voxobox.com/api/files \
  -H "X-API-Key: $KEY" -H "X-Workspace-Id: $WS"
```

`GET /auth/me` returns `{ user, plan, stats, workspaces }` — check `plan.cloud` before attempting anything that stores data. `GET /workspaces` returns the same workspace list on its own if that's all you need.

Step 2 returns `402 WORKSPACE_NOT_PAID` on a free account. That is correct behaviour, not a bug — see "What costs money".

Accounts can have more than one workspace (a personal one, plus any shared ones). Don't assume index `0` is the right target — match on `name` or `isPersonal` when it matters.

## What you can do

| Resource | Endpoints | Needs a paid plan |
|---|---|---|
| Account | get user + plan info (`GET /auth/me`) | no |
| Plans | public price + quota catalog (`GET /plans`) | no (and no auth) |
| Workspaces | list the workspaces you belong to | no |
| Models | upload, confirm, list, fetch view URL, rename/move, delete | yes |
| Folders | list, create, rename, move contents, delete | yes |
| Presets (material & lighting) | list, get, create, update, delete, export | yes |
| Shares | create a public share, fetch share metadata | yes |

Full request/response shapes live in the [OpenAPI spec](https://voxobox.com/openapi.json).

## Putting a viewer on a page

The REST API stores models. The **Viewer API** embeds one and drives it — camera,
materials, lights, post-processing, annotations, animation, screenshots — from
JavaScript, over `postMessage` into a sandboxed iframe.

Create a share for a model (`POST /files/{modelId}/share`) to get a slug, then:

```html
<div id="viewer" style="height:600px"></div>
<script type="module">
  import { VoxoboxViewer } from 'https://voxobox.com/viewer-api/v1/index.js';

  const viewer = await VoxoboxViewer.create({ iframe: '#viewer', model: 'SHARE_SLUG' });
  await viewer.camera.fit({ target: 'model' });
  await viewer.materials.update('Fabric', { albedo: { color: '#b84a3d' } });
</script>
```

Same SDK from npm, with TypeScript types: `npm install @voxobox/viewer`.

For plain HTML, Vue, Shopify, or any framework that can render custom elements:

```html
<script type="module" src="https://voxobox.com/viewer-api/v1/web-component.js"></script>
<voxobox-viewer model="https://voxobox.com/s/SHARE_SLUG" style="height:600px"></voxobox-viewer>
```

For React:

```bash
npm install @voxobox/react @voxobox/viewer
```

```tsx
import { VoxoboxViewer } from '@voxobox/react';
<VoxoboxViewer model="SHARE_SLUG" style={{ height: 600 }} />
```

Every call returns a promise and every result is plain JSON — no Three.js objects
cross the boundary. Full method list: https://voxobox.com/docs/viewer-api.html

Viewing a share needs no auth and no key. The share's owner needs a paid plan for
the model to be hosted at all; **embedding and controlling it is free**, including
from a free account's browser.

### How share visibility works (read before reporting it as a bug)

Voxobox uses an **"anyone with the link" model**, the same as an unlisted video or
a "anyone with the link" document. This is deliberate, and it is the correct model
for a viewer/embed product — but it has two properties worth stating plainly, because
they surprise people who expect share links to be access-controlled:

- **A model is served from a public CDN URL protected only by its unguessable
  address.** The URL contains two random UUIDs (~122 bits each): it cannot be guessed
  and it is never listed to anyone but the owner, so the only way to obtain it is to
  be given it (via a share or an embed). The share slug is a convenience for
  *discovery*, not an access gate — the underlying object is public for its lifetime.
- **Revoking a share (`DELETE /files/{modelId}/share`) removes the slug, but does not
  retract a CDN URL that was already handed out or embedded.** A link captured while
  the share was live keeps working, and edge caches may keep serving it for a while.
  Unshare hides the model from new discovery; it does not claw back bytes someone
  already has.

**Account isolation is fully enforced and separate from the above:** one account
cannot list, read, modify, or delete another account's models — only the person who
holds a model's link can view that one model.

**Do not host a model that must stay confidential even from someone holding its URL.**
For that requirement, Voxobox is the wrong tool as configured. Everything else — public
portfolios, product configurators, models you intend to show on the web — is exactly
what this model is for.

## File formats

| Kind | Extensions | MIME |
|---|---|---|
| 3D model | `.glb` | `model/gltf-binary` |
| 3D model | `.gltf` | `model/gltf+json` |
| Texture | `.png` | `image/png` |
| Texture | `.jpg`, `.jpeg` | `image/jpeg` |
| HDRI environment | `.hdr` | `image/vnd.radiance` |
| HDRI environment | `.exr` | `image/x-exr` |

Uploads use presigned URLs to DigitalOcean Spaces (S3-compatible). There is **no**
`POST /files` — uploading is three calls:

```bash
# 1. Ask for a presigned URL. Returns { modelId, uploadUrl, requiredHeaders }.
curl -X POST https://voxobox.com/api/files/upload-url \
  -H "X-API-Key: $KEY" -H "X-Workspace-Id: $WS" -H 'Content-Type: application/json' \
  -d '{"filename":"chair.glb","contentType":"model/gltf-binary","sizeBytes":1664}'

# 2. PUT the bytes straight to Spaces with the exact Content-Type you declared.
curl -X PUT "$UPLOAD_URL" -H 'Content-Type: model/gltf-binary' --data-binary @chair.glb

# 3. Finalize. The server verifies size, ETag and that the bytes really are a model.
curl -X POST https://voxobox.com/api/files/confirm \
  -H "X-API-Key: $KEY" -H "X-Workspace-Id: $WS" -H 'Content-Type: application/json' \
  -d '{"modelId":"'"$MODEL_ID"'","etag":"'"$ETAG"'"}'
```

Step 3 returns `415 BAD_FILE_CONTENT` if the uploaded bytes aren't the format the
filename claims — renaming a `.txt` to `.glb` will not get you a working model.

## Rate limits and etiquette

- Limits are reported in the standard (draft-7) `RateLimit` and `RateLimit-Policy` response headers — not `X-RateLimit-*`.
- On `429`, back off using `Retry-After`.
- Identify yourself with a descriptive `User-Agent` (e.g. `acme-importer/1.2 (+https://acme.example/bot)`).
- Include a stable `X-Request-Id` for traceability; if you omit it, the server generates one.

## Errors

All errors are JSON, and the shape is **flat** — `code` is a top-level field, not nested under `error`:

```json
{ "error": "This workspace requires a paid plan", "code": "WORKSPACE_NOT_PAID", "details": { "ownerPlan": "free" } }
```

Codes worth handling specifically:

| Code | Status | Meaning |
|---|---|---|
| `NO_TOKEN` | 401 | No credential sent |
| `BAD_API_KEY` | 401 | Unknown or revoked key |
| `BAD_TOKEN` | 401 | Expired or invalid JWT — refresh |
| `EMAIL_NOT_VERIFIED` | 403 | Owner must click their verification link |
| `INTERACTIVE_AUTH_REQUIRED` | 403 | Keys can't manage keys or membership — a human must do it in the browser |
| `NO_WORKSPACE` | 400 | Missing or malformed `X-Workspace-Id` |
| `WORKSPACE_NOT_PAID` | 402 | Free plan — tell your human it's $30/yr at /pricing/ |
| `STORAGE_QUOTA_EXCEEDED` | 413 | Plan storage is full |
| `FILE_TOO_LARGE` | 413 | Over the plan's per-file cap |
| `BAD_EXTENSION` / `BAD_CONTENT_TYPE` | 415 | See File formats |
| `INVALID_INPUT` | 400 | Malformed body or path param |

## Discovery

These files are designed for automated discovery:

- `/robots.txt` — crawl rules, with AI bots explicitly allowed
- `/sitemap.xml` — indexable pages
- `/llms.txt` — short site index for LLMs
- `/llms-full.txt` — full plain-text content dump
- `/openapi.json` — OpenAPI 3.1 spec
- `/.well-known/ai-plugin.json` — ChatGPT-style plugin manifest
- `/.well-known/security.txt` — vulnerability disclosure contact
