# GLX Studio Storybuilder API — Agent Guide

> **If you're an agent helping a user with their first video, fetch this entire document before doing anything else. Don't proceed from memory or summary.**

You are helping a user generate a professional video using the GLX Studio Storybuilder API. This guide contains everything you need to make API calls and walk the user through the process.

## Authentication

All requests require a Bearer token:

```
Authorization: Bearer YOUR_API_KEY
```

API keys are managed in the Developer Dashboard at `/developer`.

Rate limit: 120 requests/minute per API key. If you receive HTTP 429, wait a few seconds and retry.

## Resource ID Prefixes

All resource IDs are prefixed strings:
- Context stores: `ctx_` (e.g., `ctx_42`)
- Templates: `tpl_` (e.g., `tpl_7`)
- Storybuilder jobs: `sbv_` (e.g., `sbv_123`)
- Library collections: `col_` (e.g., `col_42`)
- Media library items: `mlf_` (e.g., `mlf_9001`)
- Segments: `sgm_` (e.g., `sgm_5582`)
- Context knowledge files: `ckf_` (e.g., `ckf_1`)
- Context media assets: `cma_` (e.g., `cma_1`)
- Projects: `prj_` (e.g., `prj_42`)
- Brand kits: `brk_` (e.g., `brk_3`)
- Scenes of a video: `scn_` (e.g., `scn_6pGioZWdQN`). A scene keeps its id for the life of the video, so it is safe to store.
- Revisions of a video: `rev_` (e.g., `rev_12`)

All id fields also accept the bare numeric form (e.g., `9001`) on input for backward compatibility, but always return the prefixed string. Scene ids are the exception: they are not numbers, so always send the full `scn_` string.

## Base URL

Use the API subdomain for your environment:
- Production: `https://api.glxstudio.com`
- Test: `https://api-test.glxstudio.com`

---

## Complete Workflow

### Step 1: Upload your content (create a context store)

A context store holds **two kinds of content**, and you can mix both in one store:

- **Knowledge files** — the documents the AI *reads* to write the script. Good examples: pitch decks, product briefs, company overviews, marketing copy, data sheets.
- **Media assets** — your own images, video clips, and audio that the AI can *use directly* in the video alongside stock footage. Uploaded media is automatically vision-tagged by AI and matched to relevant scenes when the storybuilder job runs.

The API routes each uploaded file automatically by content type — knowledge files get indexed for semantic search, media assets get vision-tagged. You don't declare which is which.

**Knowledge file formats:** PDF, DOCX, PPTX, XLSX, CSV, TXT, MD, JSON, HTML (max 256MB per file). Large files are chunked and indexed for semantic search; if a file exceeds the vector store's per-file token budget or AI processing otherwise rejects it, the file's `status` transitions to `"error"` and an `error` field on the file object describes the cause.

**Media asset formats:** JPEG, PNG, GIF, TIFF, WebP, MP4, MOV, AVI, WebM, MP3, WAV, M4A (max 1GB per file).

**Slide extraction (`extractMedia`):** PPTX and PDF files can additionally have their slides extracted as visual media assets, vision-tagged and usable in the video alongside directly-uploaded media. This is **on by default**; pass `extractMedia=false` (form field) or `"extractMedia": false` (JSON) to turn it off.

A store holds up to 100 files total (knowledge files + media assets combined). Poll `GET /context/{id}/assets` to list the vision-tagged media assets, or `GET /context/{id}` to read overall status + any per-file `error`.

**Upload via file (recommended for agents):**
```bash
curl -X POST https://api.glxstudio.com/context \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@pitch-deck.pdf" \
  -F "name=Q1 Pitch Deck"
```

**Upload multiple files at once** — repeat the `file` field (up to 100 per store). Mix knowledge files and media assets freely; the API routes each by content type (here: a deck and data sheet the AI reads, plus a product photo it can place in the video):
```bash
curl -X POST https://api.glxstudio.com/context \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@pitch-deck.pdf" \
  -F "file=@product-photo.png" \
  -F "file=@hero-clip.mp4" \
  -F "file=@data-sheet.docx" \
  -F "name=Q1 Launch Kit"
```

After media assets finish vision-tagging, list them with `GET /context/{id}/assets`, fetch one with `GET /context/{id}/assets/{assetId}`, or remove one with `DELETE /context/{id}/assets/{assetId}`. Once an asset is analyzed, its row carries an AI-generated `insights` block (title, description, coupling, provenance, brands, topics) — the same `insights` shape returned by the library members listing.

**Upload via URL:**
```bash
curl -X POST https://api.glxstudio.com/context \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Q1 Pitch Deck", "files": [{"url": "https://example.com/pitch-deck.pdf"}]}'
```

**Response (201):**
```json
{
  "contextStoreId": "ctx_42",
  "name": "Q1 Pitch Deck",
  "status": "processing",
  "fileCount": 1,
  "files": [
    { "fileId": "ckf_1", "filename": "pitch-deck.pdf", "status": "downloading" }
  ]
}
```

### Step 2: Wait for indexing to complete

Poll until `status` is `ready`. Typically takes 10-30 seconds.

```bash
curl https://api.glxstudio.com/context/ctx_42 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response when ready:**
```json
{
  "contextStoreId": "ctx_42",
  "name": "Q1 Pitch Deck",
  "status": "ready",
  "fileCount": 1,
  "files": [
    { "fileId": "ckf_1", "filename": "pitch-deck.pdf", "status": "ready", "fileSize": 2048576 }
  ]
}
```

If `status` is `error`, the file could not be processed. Ask the user to try a different file.

If you uploaded **media assets** (video/audio), the store passes through `analyzing` after `extracting`: the media is being segmented and indexed so it's understood at segment grain, not just whole-asset tagged. Keep polling until `ready` — media analysis can take longer than document indexing, especially for video.

**If indexing takes longer than ~5 minutes:** the indexing queue is occasionally backed up, especially for larger files (50MB+). Don't burn the user's foreground attention on a tight poll loop. Tell them something like *"This is taking longer than usual — the indexing queue is busy. I'll keep checking in the background and let you know when it's ready."* Then switch to a longer poll interval (60-120s) and continue watching. Resume the conversation only when `status` flips to `ready` or `error`. If you're an agent that doesn't have a true background mode, ask the user whether they'd like to wait or come back later — don't silently spin.

### Step 3: Choose a template

List the organization's templates and let the user pick one:

```bash
curl https://api.glxstudio.com/templates \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response:**
```json
{
  "templates": [
    {
      "templateId": "tpl_7",
      "name": "Product Demo",
      "status": "active",
      "variables": {
        "productName": { "required": true, "description": "Name of the product" },
        "targetAudience": { "required": false, "default": "business professionals", "description": "Who the video is aimed at" }
      },
      "defaults": { "language": "en", "voiceId": "ZF6FPAbjXT4488VcRRnw", "mediaSourcePosition": 2 }
    }
  ]
}
```

Present template names to the user and let them choose. Then inspect the `variables` to know what inputs to collect.

For full template details including the prompt text:

```bash
curl https://api.glxstudio.com/templates/tpl_7 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Step 4: Collect variable values and start generation

Ask the user for values for each required variable. Optional variables will use their defaults if not provided.

```bash
curl -X POST https://api.glxstudio.com/storybuilder \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "tpl_7",
    "contextStoreId": "ctx_42",
    "variables": {
      "productName": "Widget Pro",
      "targetAudience": "Small business owners"
    }
  }'
```

**Optional parameters:**
- `callbackUrl` — Webhook URL to receive a POST when the video is done (or fails)
- `language` — Override the template's default language (ISO 639-1 code: "en", "es", "fr", "de", "it", "pt", "ja", "ko", "zh", "ar")
- `voiceId` — Override the template's default voice. Opaque ElevenLabs voice ID tagged with a single language in GLX Studio's curated collection.
- `source` — Restrict which of *your own* media the AI draws from for visuals. `{ "collection_id": "col_42" }` scopes local-media selection to that collection (set a collection once, generate a personalized video per CSV row from it). Omit `source` to use the whole library (the default). Stock footage still fills in per the template's media-source setting when the collection has no matching shot.

```bash
# Personalize from a fixed collection, one call per CSV row
curl -X POST https://api.glxstudio.com/storybuilder \
  -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{ "templateId": "tpl_7", "contextStoreId": "ctx_42",
        "source": { "collection_id": "col_42" },
        "variables": { "customerName": "Acme" } }'
```

**Voice & language rules:**

| You override… | Resolved voice |
| --- | --- |
| nothing | template's default voice, else default voice for the template's language |
| `language` only | default voice for the new language (template's voice is ignored — may not match) |
| `voiceId` only | your `voiceId` (validated against the template's language) |
| both | your `voiceId` (validated against your new `language`) |

Each voice is tagged with exactly one language. If you pass a `voiceId` that isn't tagged for the effective `language`, the API returns `400 VOICE_LANGUAGE_MISMATCH` — the request is rejected, not silently swapped.

Simplest rule for agents: if the user wants a different language, override `language` and leave `voiceId` alone. GLX Studio will pick the correct default voice. Only pass `voiceId` when the user explicitly picks a voice from the Developer Dashboard's Voice dropdown (that's where they discover valid voice IDs for each language).

**Response (202):**
```json
{
  "jobId": "sbv_123",
  "status": "processing"
}
```

### Step 5: Poll for completion

Poll every 15-30 seconds. Total time is typically 3-8 minutes.

Status progression: `processing` (2-5 min) → `rendering` (1-3 min) → `completed`

```bash
curl https://api.glxstudio.com/storybuilder/sbv_123 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response when completed:**
```json
{
  "jobId": "sbv_123",
  "status": "completed",
  "templateId": "tpl_7",
  "contextStoreId": "ctx_42",
  "createdAt": "2026-02-17T15:30:00Z",
  "title": "Widget Pro — Built for Small Business",
  "objective": "Position Widget Pro as the time-saving operations hub for small business owners — emphasize the 30-minute setup, the integrated dashboard, and the lift on margins from automated reporting.",
  "videoUrl": "https://d2sggu5v0gbmu6.cloudfront.net/...",
  "thumbnailUrl": "https://d2sggu5v0gbmu6.cloudfront.net/...",
  "duration": 62.5,
  "brief": {
    "objective": "Position Widget Pro as the time-saving operations hub for small business owners...",
    "audience": ["Small business owners who run operations themselves."],
    "brandVoice": "Confident, plain-spoken and practical.",
    "industry": "Business software",
    "desiredDuration": "60 seconds"
  },
  "scenes": [
    {
      "id": "scn_6pGioZWdQN",
      "number": 1,
      "name": "A Clear Starting Point",
      "objective": "Open with the core value proposition.",
      "dialog": "Running a small business means wearing every hat. Widget Pro puts your whole operation on one screen.",
      "start": 4.807,
      "duration": 8.829,
      "versions": 1
    }
  ]
}
```

`brief` and `scenes` appear as soon as the script is written, which is when `status` moves from `processing` to `rendering`. You can read the script before the video exists.

**Response when failed:**
```json
{
  "jobId": "sbv_123",
  "status": "failed",
  "error": "Failed to generate creative brief from context documents"
}
```

### Step 6: Present the result

When `status` is `completed`:
- **`title`** — The video's headline title (initially the template name; replaced with the AI-generated title once the creative brief lands). Use this as the page heading.
- **`objective`** — The AI-generated "Objective of this video" from the creative brief — a one-paragraph summary of what the finished video is actually about (audience, tone, key messages). Only present once the brief has been built (i.e. once `status` advances past `processing`). Use it to style and frame any wrapper page so the visual treatment matches the video's purpose and any brand cues it mentions.
- **`videoUrl`** — Direct download link to the MP4 video. Valid for 24 hours. Can be opened in a browser, embedded in a `<video>` tag, or downloaded.
- **`thumbnailUrl`** — JPEG preview image. Valid for 24 hours. Show this as a visual preview.
- **`duration`** — Video length in seconds. Display as minutes:seconds (e.g., 62.5 → "1:02").
- **`brief`** — The creative brief the video was built from: `objective`, `audience` (a list), `brandVoice`, `industry`, `desiredDuration`.
- **`scenes`** — The script, one entry per scene: its `id`, `number`, `name`, `objective`, the narrated `dialog` as plain text, where its narration starts in the video (`start`, seconds), how long it runs (`duration`, seconds), and how many narration `versions` it has had. Use the `id` to change a scene's dialog (see **Revise a Video**).

If the URL expires after 24 hours, poll `GET /storybuilder/{id}` again to get fresh download URLs.

### Step 7: Build a viewer page

Once you have the `videoUrl`, create a simple HTML page that plays the video and styles the surrounding page to match the `objective` (and any branding it mentions — colors, product name, audience tone). Use `thumbnailUrl` as the `<video poster>` so the page has a visual before playback starts. Open it in the user's browser so they can watch their video.

**Don't autoplay.** Set the `<video>` element with `controls` only — no `autoplay`. Modern browsers mute autoplaying video, which means the user would miss the narration on the first viewing. Let them click play.

**Deliver it the most direct way your environment supports** — render it inline if your host has a preview panel, otherwise save the file and open it via the OS, serve it over a quick local HTTP server, or share a path the user can open themselves. Pick one and ship it; don't ask the user to test or troubleshoot playback.

---

## Revise a Video

After a video is completed you can change what any scene says. The scene is voiced again in the same voice, the clips after it move to fit the new length, and you render when the wording is right. The visuals, scene order and voice do not change; dialog is the only thing a revision edits.

The flow is built for review: **change dialog, listen, change again, then render once.** Voicing a scene does not render the video, so you can make as many corrections as you like before spending a render.

### 1. Read the scenes

```bash
curl https://api.glxstudio.com/storybuilder/sbv_123/scenes \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
{
  "jobId": "sbv_123",
  "brief": { "objective": "...", "audience": ["..."], "brandVoice": "...", "industry": "...", "desiredDuration": "60 seconds" },
  "scenes": [
    {
      "id": "scn_6pGioZWdQN", "number": 1, "name": "A Clear Starting Point",
      "objective": "Open with the core value proposition.",
      "dialog": "Running a small business means wearing every hat. Widget Pro puts your whole operation on one screen.",
      "start": 4.807, "duration": 8.829, "versions": 1,
      "audioUrl": "https://..."
    }
  ],
  "renderPending": false,
  "nextRenderFree": true
}
```

- **`audioUrl`** — The scene's current narration as an MP3. Valid for 24 hours; call the endpoint again for a fresh link. It is only present once the audio file is stored, so a link you receive always plays.
- **`renderPending`** — `true` when dialog has been changed since the last render, meaning the video does not yet say what `scenes` says.
- **`nextRenderFree`** — Whether the next render is included or will use one credit (see **Credits** below).

### 2. Change the dialog

Send the scenes you want to change, each with its **complete new dialog** (the text replaces the scene's dialog; it is not a find-and-replace). Up to 20 scenes per request, 1,500 characters per scene.

```bash
curl -X POST https://api.glxstudio.com/storybuilder/sbv_123/revisions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "scenes": [
      { "id": "scn_6pGioZWdQN",
        "dialog": "Running a small business means wearing every hat. Widget Pro turns all of it into one simple dashboard." }
    ]
  }'
```

**Response (HTTP 202):**
```json
{ "revisionId": "rev_12", "jobId": "sbv_123", "status": "voicing", "rendered": false, "scenes": [] }
```

The response comes back immediately, before any audio exists. Voicing takes about 5 seconds per scene.

### 3. Poll the revision, then listen

```bash
curl https://api.glxstudio.com/storybuilder/sbv_123/revisions/rev_12 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Poll every 2-5 seconds. Status progression: `voicing` → `rebuilding_charts` (only when a chart has to grow to cover a longer scene) → `completed`.

```json
{
  "revisionId": "rev_12",
  "jobId": "sbv_123",
  "status": "completed",
  "rendered": false,
  "renderPending": true,
  "nextRenderFree": true,
  "next": "The video still shows the previous dialog. POST /storybuilder/sbv_123/render to render it.",
  "scenes": [
    { "id": "scn_6pGioZWdQN", "number": 1, "status": "voiced",
      "duration": 9.41, "change": 0.58,
      "audioUrl": "https://..." }
  ]
}
```

Each scene appears in `scenes` as soon as it is voiced, so a long revision fills in while you poll. Per scene:
- **`status`** — `voiced`, `unchanged` (the dialog you sent is what the scene already says) or `failed` (with `error`).
- **`duration`** / **`change`** — The new narration's length, and how many seconds longer (or shorter, when negative) it is than before.
- **`audioUrl`** — The new narration as an MP3, valid for 24 hours. Play it for the user before rendering.

Repeat steps 2 and 3 as often as needed. One revision runs at a time per video; a second request while one is in progress returns `409 REVISION_IN_PROGRESS`.

### 4. Render the video

When the wording is right, render once:

```bash
curl -X POST https://api.glxstudio.com/storybuilder/sbv_123/render \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Always send a JSON body, even an empty `{}`. The response (HTTP 202) is a revision with no scenes; poll it at `GET /storybuilder/sbv_123/revisions/{revisionId}` every 15-30 seconds. Status goes `rendering` → `completed`, typically in 1-3 minutes, and the completed revision carries the new `videoUrl`, `thumbnailUrl` and `duration`. `GET /storybuilder/sbv_123` returns the new video from then on.

`409 NOTHING_TO_RENDER` means the video already says what the scenes say.

### One call instead of two

To change dialog and render in a single request, add `"render": true` to the revision:

```json
{ "render": true, "scenes": [ { "id": "scn_6pGioZWdQN", "dialog": "..." } ] }
```

The revision then goes `voicing` → `rendering` → `completed` and ends with the new `videoUrl`. Use this for a one-off fix; use the staged flow above when someone will review the result.

### Credits

- Changing dialog and voicing scenes is free.
- Each video includes **one free update render** after it is created. Every render after that uses one credit.
- `nextRenderFree` on the scenes and revision responses tells you which applies before you render.
- With no credits left, a render request returns `402 INSUFFICIENT_CREDITS`. The dialog changes are kept; render again once credits are available.

### Revision callbacks

Add `"callbackUrl"` to a revision or render request to be notified instead of polling. If you leave it out, the callback URL given when the job was created is used. The payload is the revision as `GET .../revisions/{revisionId}` returns it, plus an `event` field: `revision.completed` or `revision.failed`. Three attempts are made (after 5 s and 30 s); return HTTP 2xx to acknowledge.

### Revision errors

| HTTP Status | Code | Meaning |
|---|---|---|
| 400 | `BAD_REQUEST` | `scenes` missing or empty, more than 20 scenes, a scene listed twice, empty dialog, dialog over 1,500 characters, or `render` not `true`/`false` |
| 402 | `INSUFFICIENT_CREDITS` | The render needs a credit and none are left |
| 404 | `SCENE_NOT_FOUND` | A scene id is not part of this video |
| 404 | `NOT_FOUND` | The job or revision does not exist or belongs to another organization |
| 409 | `JOB_NOT_COMPLETE` | The video is still being built or rendered; revise it once the job is `completed` |
| 409 | `REVISION_IN_PROGRESS` | Another revision or render of this video is still running |
| 409 | `NOTHING_TO_RENDER` | Nothing has changed since the last render |

---

## Additional Endpoints

### List existing context stores

Check if a suitable context store already exists before creating a new one:

```bash
curl https://api.glxstudio.com/context \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Add files to an existing context store

Enrich an existing context store with more content — knowledge files, media assets, or both. Send one file, or several at once by repeating the `file` field (combined total ≤ 100 per store). Each file is routed by content type exactly as on create: documents get indexed, images/video/audio get vision-tagged, and `extractMedia` (default on) still extracts PPTX/PDF slides as media:

```bash
curl -X POST https://api.glxstudio.com/context/ctx_42 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@additional-doc.pdf" \
  -F "file=@product-photo.png" \
  -F "file=@b-roll.mp4"
```

You can also add multiple files by URL: `{"files": [{"url": "..."}, {"url": "..."}]}`. The newly added media assets show up under `GET /context/ctx_42/assets` once vision-tagging completes.

### Delete a context store

```bash
curl -X DELETE https://api.glxstudio.com/context/ctx_42 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

---

## Media Library API (Collections & Ingest)

The Media Library lets you build a reusable, searchable body of a company's own footage: ingest video/images into **collections**, the platform analyzes them into searchable segments, and you can later retrieve matching segments (and download them as clips) or drive a Storybuilder generation from a collection. This is separate from context stores (which are document knowledge for a single video).

A **collection** is a curated, segment-grained set. Members are video **segments** (`{"segment_id": "sgm_5582"}`) or whole non-segmented media like images (`{"media_id": "mlf_9001"}`, a media library item). When you ingest a whole video into a collection, the file is added as a media member and resolves to all of its segments once analysis completes.

### Create a collection

```bash
curl -X POST https://api.glxstudio.com/library/collections \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "H Corps Event 2026"}'
```

**Response (201):**
```json
{ "id": "col_42", "name": "H Corps Event 2026", "mode": "set",
  "created_by": "agent", "status": "active", "member_count": 0 }
```

List with `GET /library/collections` (optional `?subkind=brand`); read one with `GET /library/collections/col_42`.

### Add files to the library

Upload bytes **directly to one-time upload URLs** — they never pass through the API. Three steps: request upload URLs, upload the bytes, then confirm.

Collections are **optional**. Omit them to ingest straight into the company library (unfiled) — you don't have to create a collection just to upload. Either way the media is analyzed, indexed, and searchable; a collection is purely an organizing layer you can add now or curate later.

A file (and all of its analyzed segments) can belong to **more than one collection** — membership is many-to-many. Pass `collection_ids` to file the uploads into one or several collections at ingest time; for a single collection, pass a one-element array (`["col_42"]`). Whatever you pass (or omit) at **initiate** must match at **complete**.

**Step 1 — initiate** (batch; one call for many files). Unfiled — no collection:
```bash
curl -X POST https://api.glxstudio.com/library/ingest \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "files": [ {"filename": "keynote.mp4", "content_type": "video/mp4", "size_bytes": 524288000} ] }'
```

To file the uploads into collections at the same time, add `"collection_ids": ["col_42", "col_77"]` alongside `files` (the file and its segments become members of every listed collection). For a single collection, pass a one-element array: `"collection_ids": ["col_42"]`.

**Response (200)** — files ≤5GB get a single upload URL (`mode: single`); files >5GB get a chunked upload (`mode: multipart`) with one URL per part (`collection_ids` is echoed only when you supplied one or more):
```json
{ "files": [
    { "upload_ref": "34601", "filename": "keynote.mp4",
      "upload": { "mode": "single", "url": "https://uploads.glxstudio.com/..." } }
  ] }
```

Chunked shape (for a >5GB file):
```json
{ "upload": { "mode": "multipart", "upload_id": "<upload-id>", "part_size": 104857600,
              "parts": [ {"part_number": 1, "url": "https://uploads.glxstudio.com/...part-1..."}, … ] } }
```

**Step 2 — upload the bytes:**
- *single*: `PUT` the whole file to `upload.url`.
- *multipart*: `PUT` each chunk (`part_size` bytes) to its part `url`, and capture the `ETag` response header of each part.

```bash
curl -X PUT --upload-file keynote.mp4 "https://uploads.glxstudio.com/..."
```

**Step 3 — complete** (carry the same `collection_ids` you used at initiate, or omit them if you ingested unfiled; include `upload_id` + `parts` only for multipart):
```bash
curl -X POST https://api.glxstudio.com/library/ingest/complete \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "files": [ {"upload_ref": "34601"} ] }'
```
Chunked-upload complete includes the ETags you captured:
```json
{ "files": [ {"upload_ref": "34601", "upload_id": "<upload-id>",
              "parts": [ {"part_number": 1, "etag": "\"abc123…\""}, … ] } ] }
```

**Response (200):**
```json
{ "files": [ {"media_id": "mlf_9001", "upload_ref": "34601", "status": "processing"} ] }
```

The file is now in the library (and a member of every collection you supplied) and analysis runs in the background (video → searchable segments, audio → transcript + audio classification, images → indexed). The 120GB "dump it now, find it later" case is just this loop run per file — upload in parallel and walk away. You can add items to (or remove them from) collections any time later with `PUT /library/collections/{id}`.

### Check readiness

Poll the collection to see how much of the dump is analyzed and searchable:
```bash
curl https://api.glxstudio.com/library/collections/col_42 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Curate members

Add or remove specific members, or rename, with `PUT` (segments by `segment_id`, whole media by `media_id`):
```bash
curl -X PUT https://api.glxstudio.com/library/collections/col_42 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "add": [ {"segment_id": "sgm_5582"} ], "remove": [ {"media_id": "mlf_9001"} ] }'
```
List members with `GET /library/collections/col_42/members?limit=25&offset=0`. **Paginated** (`limit` default 25, max 100; response carries `total` + `has_more`). Each member carries the same typed, prefixed handle the rest of the API uses (so it feeds straight back into add/remove, query, and export), plus per-item metadata and the AI-generated **`insights`** the platform extracted:
```json
{ "total": 42, "limit": 25, "offset": 0, "has_more": true,
  "members": [
    { "type": "media", "media_id": "mlf_9001",
      "media": { "filename": "keynote.mp4", "format": "video", "duration": 1820.5,
                 "width": 1920, "height": 1080, "size_bytes": 524288000 },
      "insights": { "title": "Q1 Keynote", "description": "A presenter on stage…",
                    "coupling": "coupled", "provenance": "raw",
                    "brands": ["Acme"], "topics": ["growth strategy","roadmap"] } },
    { "type": "segment", "segment_id": "sgm_5582", "in": 912.9, "out": 928.4, "duration": 15.5,
      "insights": { "title": "Speaker at podium", "scene_type": "presentation",
                    "shot_type": "medium", "tempo": "moderate", "role": "a_roll",
                    "emotions": ["confident"], "literals": ["podium","microphone"],
                    "ocr_text": "Q1 RESULTS", "dialog": "…what artificial intelligence is…" } }
  ] }
```
`insights` is AI analysis (computer-vision tagging + transcript synthesis); fields are present only when known. You can only add media your company owns; an inaccessible id returns `403`.

### Search for segments

Search a company's analyzed footage for the segments that best match an intent. Send plain intent — the platform splits it into a visual lens, a spoken term, and filters, runs vector recall, and returns segments in **raw vector order**. Set `rerank: true` (opt-in, default off) to add an LLM judge that re-orders the candidates by a **calibrated confidence** and attaches an **evidence** block you can show a user, scoring each on what's **shown** and what's **said** — this adds latency.

By default the query searches the **whole company library** (every analyzed file, filed or unfiled). Pass `collection_id` only when you want to narrow it to one collection.

```bash
curl -X POST https://api.glxstudio.com/library/query \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "query": "the best 6 seconds of someone on camera saying artificial intelligence",
        "limit": 8, "rerank": true }'
```

To narrow the same search to one collection, add `"collection_id": "col_42"`.

Response (with `rerank: true` — `confidence` and `evidence` are added by the judge):
```json
{ "results": [
    { "media_id": "mlf_9001", "segment_id": "sgm_5582", "source": "library", "media_format": "video",
      "in": 912.9, "out": 928.4, "duration": 15.5, "score": 0.736, "confidence": 0.9,
      "evidence": { "visual": "Man speaking to camera in an interview",
                    "spoken": "...what artificial intelligence is...",
                    "why": "On-camera speaker, says the phrase" } } ] }
```

- `query` is freeform intent; the platform derives the visual/spoken/filter split for you. To skip that and control retrieval yourself, send explicit `visual`, `spoken`, and/or `filters` (`{min_duration, max_duration, role, media_format}`) — any explicit field bypasses the pre-processor.
- `collection_id` is an optional narrowing filter — scopes the search to one collection. Omit it (the default) to search the whole company library.
- `rerank` is **opt-in** and defaults to `false`: omit it for fast raw vector results, or set `"rerank": true` for a calibrated `confidence` (0..1) plus an `evidence` block. When reranked, threshold on `confidence`, not the raw `score` — a reranked query with no genuine matches returns few or zero results rather than forcing weak ones. Without rerank, results carry only `score` (raw vector similarity) in vector order.
- `limit` caps results (default 10). `in`/`out` are seconds into the source media. Set `"preprocess": false` to send your `query` straight to the vector.

### Download a segment (export a clip)

To download a matching segment, just pass its `segment_id` from `/library/query` — that's all you need; the platform resolves the segment's in/out, trims it, and returns a downloadable MP4 clip. Results are cached, so re-requesting the same segment is instant.

```bash
curl -X POST https://api.glxstudio.com/library/export_clip \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "clips": [ { "segment_id": "sgm_5582" },
                    { "segment_id": "sgm_5601" } ] }'
```

Response:
```json
{ "clips": [
    { "segment_id": "sgm_5582", "duration": 15.5,
      "status": "ready", "cached": false,
      "url": "https://.../clips/...mp4?...", "expires_in": 10800 } ] }
```

- Identify each clip by its **`segment_id`** (the simplest path — it resolves its own in/out). For a **custom range** that isn't an analyzed segment, pass `{ "media_id", "in", "out" }` instead. Send a single object at the top level or a `clips` array (up to 25).
- `url` is a temporary download link, valid for **3 hours** (`expires_in` seconds), served as an attachment. `cached: true` means it was already trimmed (returned instantly).
- `status` is `ready` (URL present), `source_not_ready` (the media is still being processed), or `failed`. The clip is the proxy (preview) quality.

---

## Callback Webhook

Instead of polling, provide a `callbackUrl` when creating a job. The API will POST to your URL when the video is ready or fails.

**Success payload:**
```json
{
  "jobId": "sbv_123",
  "status": "completed",
  "videoUrl": "https://...",
  "thumbnailUrl": "https://...",
  "duration": 62.5
}
```

**Failure payload:**
```json
{
  "jobId": "sbv_123",
  "status": "failed",
  "error": "Error description"
}
```

Retry policy: 3 attempts with exponential backoff (5s, 30s, 120s). Return HTTP 2xx to acknowledge.

---

## Error Responses

All errors return JSON with `error` and `code` fields:

```json
{ "error": "Context store not found", "code": "NOT_FOUND" }
```

| HTTP Status | Meaning |
|---|---|
| 400 | Bad request — missing fields, invalid IDs, context store not ready, `VOICE_LANGUAGE_MISMATCH` |
| 401 | Unauthorized — missing or invalid Bearer token |
| 402 | No credits remaining (`INSUFFICIENT_CREDITS`) |
| 404 | Resource not found or does not belong to your organization |
| 409 | The resource is not in a state that allows the request (`JOB_NOT_COMPLETE`, `REVISION_IN_PROGRESS`, `NOTHING_TO_RENDER`) |
| 429 | Rate limit exceeded — wait and retry |
| 500 | Server error — retry or contact support |

---

## Tips for AI Agents

- **Reuse context stores.** If the user has already uploaded a document, check `GET /context` before creating a new one.
- **Poll conservatively.** Every 15-30 seconds is sufficient. Avoid tight loops.
- **Show progress.** Tell the user generation takes 3-8 minutes. During `processing` the AI is writing the script; during `rendering` the video is being assembled.
- **Handle errors gracefully.** If a context store has `status: "error"`, suggest trying a different file. If a job fails, show the error message and offer to retry.
- **Duration formatting.** Convert seconds to mm:ss for display (e.g., `Math.floor(62.5/60)` + ":" + `Math.floor(62.5%60).toString().padStart(2,'0')` → "1:02").
- **Revise before you render.** When the user wants wording changed, send revisions, play each scene's `audioUrl` for them, and call `/render` only once they approve. A video has one free update render, so rendering after every small change costs credits.
- **Send the whole dialog.** A revision replaces a scene's dialog. Read the current text from `GET /storybuilder/{id}/scenes`, edit it, and send the full result.
- **Keep scenes close to their length.** New dialog that is much longer or shorter changes the scene's duration, and the same visuals are stretched or shortened to cover it. Small rewrites look best.
- **URL expiration.** Video, thumbnail and audio URLs expire after 24 hours. If presenting a result from a previous session, re-poll the job to get fresh URLs.
