Skip to content

Content Studio — Proteus API Reference

Summary

The Proteus API is how an organization authors reusable universal assets — characters, locations, looks, voices, props and products — and casts them into productions by reference. It covers four route families: org-level asset authoring (/org-assets), world bundles (/mythos), brands and their rights (/brands), and product placement (/productions/:id/placements), plus production deletion.

For scenes, beats, coverage, ensembles and entity versions — how a script becomes shots — see the companion Production Pipeline API reference.

Feature architecture: Proteus — Universal Asset System.

  • Authentication
  • Token cost
  • Org assets
  • Worlds (Mythos)
  • Brands (Kleos)
  • Placements (Kleos)
  • Productions
  • Errors

Authentication

All endpoints require a bearer JWT and resolve the caller's organization.

Authorization: Bearer <jwt>
X-Organization-Id: <org-uuid>

The organization may also be supplied as the organization_id query parameter or the magieva-selected-org-id cookie. Precedence is header → query → cookie.

⚠️ Mutating requests require an explicit organization. POST, PATCH, PUT and DELETE are refused with 400 no organization selected when no organization resolves. There is no default organization — a request that does not say which organization it is acting for is rejected rather than being applied to whichever one happens to be first. GET requests are not subject to this gate.

Every resource is organization-scoped. An id belonging to another organization reads as 404, not 403 — existence is not disclosed.

Token cost

Most endpoints are metadata operations and are quota-exempt — they write rows, flip flags or record decisions and call no AI provider. Two endpoints spend intelligence tokens and are gated by the standard quota middleware.

Endpoint Cost
POST /org-assets/describe Token-gated — one short LLM turn
POST /org-assets/:uaId/options/generate Token-gated — image generation, estimate scales with iterations
Everything else in this reference Quota-exempt

A token-gated call that exceeds the organization's quota returns 402 from the quota gate before the handler runs. See Intelligence Tokens.


Org assets

The org-level authoring substrate. An asset is authored as a draft at organization scope (no production), runs the concept → approve → clearance discipline, and is then published into the organization's library.

POST /org-assets

Create a draft. Quota-exempt.

Request

{
  "asset_type": "character",
  "name": "Aurora",
  "appearance": "mid-thirties, close-cropped silver hair, field jacket",
  "license": "org-internal",
  "tags": ["lead", "season-1"],
  "brand_id": null
}
Field Type Required Notes
asset_type string character, location, set, prop, fx, style, voice, music, camera, product
name string
appearance string The generation prompt. A blank one produces a generic asset.
license string Defaults to org-internal
tags string[] Defaults to []
brand_id uuid Kleos. Must be a brand owned by this organization, else 404
metadata object Studio bookkeeping — production code, readiness, blockers, source anchor, sheets. Stored verbatim; returned on every list row; merged on PATCH

Response 200

{
  "success": true,
  "universal_asset_id": "…",
  "lineage_id": "…",
  "status": "draft"
}

GET /org-assets

List assets. Quota-exempt.

Query param Default Notes
status draft draft, published, archived
asset_type Filter by type
q Substring match on name or appearance
all_versions false See below

One row per asset, not per version. Results are collapsed by lineage_id, and each row carries versions (count) and lineage_usage_count (total reuse across the lineage) so a row can show "3 versions" and total reuse without a second round trip. Pass all_versions=true for the flat, uncollapsed list.

PATCH /org-assets/:uaId

Rename a draft or rewrite its description. Quota-exempt. Sets updated_at.

{ "name": "Aurora Vale", "appearance": "…", "brand_id": "…", "metadata": { "keyframe_readiness": "PREVIS READY" }, "tags": ["gna2", "hero"] }

All fields optional. brand_id is set-only in the current slice — clearing a brand ships with the brand picker. name, appearance and brand_id are draft-only (a published asset is pinned by the productions that imported it — use New version). metadata (merged, top-level keys win) and tags (replaced wholesale) are bookkeeping, not identity, and are accepted on published assets too. Archived assets are refused (409).

POST /org-assets/describe

Token-gated. Turn a name into a description worth generating from.

{ "name": "Aurora", "asset_type": "character", "existing": "" }

POST /org-assets/:uaId/options/generate

Token-gated. Generate candidate concepts. iterations controls how many; the quota estimate scales with it.

{ "iterations": 3 }

GET /org-assets/:uaId/options

List generated options. Quota-exempt.

{ "options": [  ], "total": 3 }

POST /org-assets/:uaId/options/:taskId/approve

Pick an option. Quota-exempt — the option was already paid for at generation time.

POST /org-assets/:uaId/set-reference

Bring your own canon. Turn an already-uploaded image (a partner turntable, a keyframe reference sheet) into a Looks version of the asset — and, with approve, into its reference image in the same call. Quota-exempt: it records an upload that already happened.

{ "asset_id": "…", "approve": true, "label": "CHR2-001 · MODEL + WARDROBE", "metadata": { "sheet_type": "MODEL + WARDROBE" } }
Field Type Required Notes
asset_id uuid An assets row this organization owns (upload it via POST /api/storage/upload first). Another org's asset reads as 400 asset not found
approve bool Also set reference_image_url and flip the concept gate to approved
label string Shown on the version card
metadata object Stored on the synthetic task and the asset link

Response 200

{ "success": true, "universal_asset_id": "…", "task_id": "…", "asset_id": "…", "version": 2, "approved": true, "reference_image_url": "https://…" }

The upload appears in GET /org-assets/:uaId/options as a completed image_to_image version (source: manual_upload), so it can also be approved later through the normal approve route. Both routes share one write, so a generated look and an uploaded sheet cannot drift. Refuses with 409 when the asset has no readable URL yet or the org asset is archived.

GET /org-assets/:uaId/pipeline

The asset's pipeline stages: stage, status, artifact_asset_id, approved_by, approved_at, notes, metadata, updated_at.

POST /org-assets/:uaId/pipeline/:stage

Set a stage. Quota-exempt — flips a gate flag.

{ "status": "approved", "artifact_asset_id": "…", "notes": "", "metadata": {} }

Upserts on (entity_type, entity_id, stage).

GET /org-assets/:uaId/clearance

{
  "clearances": [ { "rights_type": "likeness", "status": "granted", "holder": "…" } ],
  "blocked": false,
  "block_reason": ""
}

POST /org-assets/:uaId/clearance

Record a rights decision. Quota-exempt.

{
  "rights_type": "likeness",
  "status": "granted",
  "holder": "…",
  "reference": "…",
  "expires_at": "2027-01-01T00:00:00Z",
  "notes": ""
}

POST /org-assets/:uaId/publish

Publish a draft into the organization library. Quota-exempt — publishes what is already built.

Clearance-gated. Returns 403 rights not cleared: <reason> if any clearance blocks.

Only a draft can be published. Republishing, or publishing another organization's asset, affects zero rows and returns 404.

{ "success": true, "universal_asset_id": "…", "status": "published" }

Deleting a published asset archives it. status becomes archived: hidden from the library and from pickers, unusable in worlds, but the row survives so every production that imported it keeps its provenance.


Worlds (Mythos)

A world bundles published assets — cast, standing locations, look, voices — under one name, so a new production can start already populated. All quota-exempt: naming a world and listing what is in it generates nothing.

POST /mythos

{ "name": "Hollow Pines", "description": "…", "metadata": { "production": "…", "cover_asset_id": "…" } }

201. Returns 409 if a world of that name already exists in the organization. metadata is the world's bible — source folder, registers, cover, counts, standards documents. It is bookkeeping, never authoritative for membership. PATCH /mythos/:id accepts metadata too and merges it (top-level keys win).

GET /mythos

List the organization's worlds.

GET /mythos

Each world carries member_count, published_count (what a production can start from), draft_count (still in development) and metadata.

GET /mythos/:mythosId

A world and its members. Each member carries what a board needs without a second round trip: lineage_id, version, status, appearance, reference_image_url, apose_url, rigged_glb_url, lora_url, trigger_word, has_rig, has_lora, tags, metadata, gates (stage → status from asset_pipeline_stages), usage_count, updated_at; plus members_by_type, published_count and draft_count on the world.

POST /mythos/:mythosId/members

{ "universal_asset_id": "…", "allow_draft": true }

A published asset always joins. A draft joins only with allow_draft: true — the world is then the bible under development, not just the shelf of finished identities. Production instantiation (castAtCreation) imports published members only, so admitting a draft never hands a production a half-finished identity. Archived assets are refused (409). Returns 409 this world has no backing group if the world's backing group is missing.

DELETE /mythos/:mythosId/members/:uaId

Remove a member. The asset itself is untouched.

DELETE /mythos/:mythosId

Delete the world. Removes the membership structure only — member assets are not deleted. Backing-group teardown is best-effort.

Instantiating a production from a world

Worlds are consumed at production creation, not through this route family. POST /productions accepts three casting variables:

Variable Meaning
variables.cast[] Universal asset ids — import each
variables.selected_library_ids[] Group ids — import their universal-asset members
variables.mythos_ids[] World ids — resolve to groups, then as above

The 201 response body includes a cast object reporting what actually happened:

{
  "cast": {
    "imported": [ { "universal_asset_id": "…", "name": "Aurora" } ],
    "failed":   [ { "universal_asset_id": "…", "error": "…" } ],
    "warnings": ["representation copy failed: un-posable"]
  }
}

Casting is best-effort — a failed import never fails production creation — but it is never silent. A world that contributed nothing says so, rather than yielding a normal-looking empty production.


Brands (Kleos)

POST /brands

Quota-exempt — naming a brand generates nothing.

{
  "name": "Halcyon",
  "brand_kind": "fictional",
  "guidelines": {},
  "consent_ref": null,
  "parent_brand_id": null
}
Field Required Notes
name Unique per organization, case-insensitive
brand_kind real or fictional — anything else is 400
guidelines Free-form object
consent_ref Pointer to the consent artifact
parent_brand_id Sub-brand hierarchy

201. Returns 409 on a duplicate name within the organization.

Fictional brands are born with rights granted. Real brands start uncleared and must be cleared before use.

GET /brands

{ "brands": [  ], "total": 4 }

Any asset type may carry a brand_id — a product, a jingle, a look, a flagship location — so brands are not product-only.

POST /brands/:brandId/rights

Quota-exempt — records a rights decision. This is the transition the clearance gate depends on: grant when the licence lands, revoke when it lapses.

{ "rights_status": "granted", "consent_ref": "…", "notes": "MSA signed 2026-08-04" }
Field Required Notes
rights_status none | pending | granted | revoked. Anything else is 400 naming the options
consent_ref Pointer to the consent artifact. Omitted or empty leaves the existing value
notes Free text, stored on the brand

200

{ "brand_id": "…", "rights_status": "granted", "previous_rights_status": "pending" }

409 on a fictional brand:

{
  "error": "a fictional brand has no external rights to record",
  "detail": "Fictional brands are cleared by construction. Only 'real' brands carry a rights status."
}

The refusal is deliberate rather than accepting a write the gate would then ignore — a UI showing revoked on a brand that still renders is worse than not offering the control at all.

Revoking is not retroactive, and not a delete. Work already delivered under a licence stays valid; only the next render is refused. That is why this writes a status rather than unpicking placements. Both ends of the transition are logged at INFO, because "who could film this brand, and from when" cannot be answered from the current value alone.

DELETE /brands/:brandId

Quota-exempt. A hard delete — unlike a published asset's archive, a brand carries no provenance of its own; it is a label.

409 while anything still wears it:

{
  "error": "this brand is still in use",
  "detail": "Deleting it would leave assets and placements pointing at a brand that no longer exists, and a missing brand reads as 'no brand' at the clearance gate — which would unblock them. Revoke its rights instead, or detach it from what carries it first.",
  "assets": 3,
  "placements": 1,
  "sub_brands": 0
}

The count includes archived assets — an archived asset's provenance still names its brand.


Placements (Kleos)

A placement answers "which Product fills which prop-role in this production, under which terms". It reuses the cast shape exactly: a role in the story (production_props) ← the cast identity (a Product asset lineage), with UNIQUE(version_id, prop_id) enforcing one product per prop-role per version, the way casting enforces one performer per character.

brand_id is denormalized from the product at write time, so terms and reporting read it without joining back through the asset system — and it records the brand as it was when the deal was struck.

POST /productions/:id/placements

Quota-exempt — naming a placement generates nothing and calls no provider.

{
  "prop_id": "…",
  "product_lineage_id": "…",
  "placement_terms": { "exclusivity": "category", "territory": "NA" }
}
Field Required Notes
prop_id Must belong to this production
product_lineage_id Must resolve to a published product asset in the caller's org
placement_terms Free-form object, stored as jsonb

201

{
  "placement_id": "…",
  "prop_id": "…",
  "product_lineage_id": "…",
  "product_name": "Halcyon Field Watch",
  "brand_id": "…"
}

Both halves of the pairing are validated at write time. A caller who may write to production A must not be able to name a prop belonging to production B, and a product from another organization must not become filmable here just because the production is ours. Either failure reads as 404 (prop / published product) — the caller learns nothing about what they cannot see. Draft products are not castable; archived ones are gone.

409 when the prop-role is already filled:

{ "error": "this prop-role already has a product placed in the current version" }

Not gated on clearance, deliberately. A placement is the record of a deal, and deals get recorded while the licence is still being negotiated. Refusing the write would also make the placement panel's "blocked" badge unreachable, since the row it renders could never exist. Enforcement belongs on the path that turns a placement into imagery.

GET /productions/:id/placements

The production's placements for its current version, with product and brand resolved so the Assets phase renders a row without N follow-up calls.

DELETE /productions/:id/placements/:placementId

Quota-exempt.


Productions

DELETE /productions/:id

Delete a production and everything under it.

Returns 409 while an assembly is actively compiling:

{ "error": "an assembly is compiling — wait for it to finish (or for the reaper to fail it) before deleting" }

A stuck assembly is failed automatically after 45 minutes, so this state resolves on its own.

200

{ "success": true, "production_id": "…" }

Errors

Status Meaning
400 Malformed id or body; or a mutating request with no organization selected
401 Missing or invalid authentication
402 Token quota exceeded (token-gated endpoints only)
403 rights not cleared: <reason> — publish blocked by clearance
404 Not found — including resources owned by another organization
409 Conflict — duplicate name, assembly compiling, or a precondition unmet
500 Server error

Error bodies are { "error": "…" }, sometimes with a detail field.


  • Universal assets overview — how assets, versions and worlds fit together
  • Intelligence tokens — how token-gated endpoints are billed and quoted