openapi: 3.1.0
info:
  title: Portal Nexus — Generation API
  version: "1.1.0"
  description: |
    Asset generation surface for external integrations (e.g. the Minds Bazaar
    skill). Covers synchronous generation, async jobs for slow categories,
    project folder management, and balance checks. Scoped to what an
    agent-driven skill calls at runtime —
    key creation/management happens once, out of band, via the Nexus dashboard
    (`/dashboard/keys`), not through this API.

    ## Authentication
    Every endpoint requires `Authorization: Bearer <api-key>`. Bearer is the
    only supported scheme — no `x-api-key`-style header.

    Keys come in two forms, otherwise functionally identical (same base URL,
    same schemas, same real-or-mock provider dispatch per category):
    - `gr_live_...` — real key, billed against the account's real shard balance.
    - `gr_test_...` — sandbox key, for pre-launch integration testing. Minted
      the same way as a live key (via `/dashboard/keys`, selecting the "Test"
      environment) — there's no separate test API surface to point at.

    A Portal AIR session token is also accepted on these endpoints, but that's
    the Nexus dashboard's own auth path, not relevant to external integrations.

    ## Billing
    Every generation (synchronous or async) reserves and captures "shards"
    from the calling account's balance, keyed by the API key used — Studio,
    MCP, the Nexus UI, and this API all bill identically, there is no
    unmetered path. A fresh account starts with a default free-tier balance;
    coordinate separately if a specific integration needs it topped up.

    ## Real vs. mock output
    Every category has at least one real, configured provider except `music`
    (Mubert is not currently configured) — an unconfigured or partially-tried
    provider degrades gracefully to a mock output rather than erroring, so a
    call always succeeds structurally even when the asset itself is a
    placeholder. Check `output.format` / the returned URL if you need to
    distinguish a mock (`https://cdn.gamerouter.dev/mock/...`) from a real
    provider result.

    ## Reference inputs (image-to-image, reference audio)
    Pass `inputFiles` — always a URL in the JSON body, never multipart:
    ```json
    "inputFiles": [{ "url": "https://...", "name": "ref.png", "mimeType": "image/png" }]
    ```
    Only the first entry is currently used. Malformed entries (missing `url`)
    are dropped rather than rejecting the whole request; `name`/`mimeType`
    default if omitted.

    ## Sync vs. async
    `/api/v1/generate/{category}` blocks for the duration of the generation
    (up to a 300s server-side ceiling). For `3d` specifically — the slowest
    category — prefer `/api/v1/jobs` (submit + poll) instead of the
    synchronous endpoint. Other categories are safe synchronously.

    ## Rate limits
    Default 120 requests/minute per key, configurable per key. `429
    rate_limited` when exceeded.

    ## Every response
    Carries an `X-Request-Id` response header (also echoed as `id` in
    generation responses) for support/debugging correlation.
  contact:
    name: Portal Nexus
  license:
    name: Proprietary — internal/partner use only, not publicly licensed.
servers:
  - url: https://www.portalgaming.com/nexus
    description: >-
      Production. Nexus lives under /nexus on the Portal domain;
      portalgaming.com redirects to www, and nexus.portalgaming.com is retired.
  - url: https://develop.portalgaming.com/nexus
    description: Staging (the develop branch).

security:
  - bearerAuth: []

tags:
  - name: Generation
    description: Synchronous asset generation, one category per endpoint.
  - name: Jobs
    description: Async submit-and-poll generation, for slow categories (3D).
  - name: Account
    description: Balance / credit checks.
  - name: Projects
    description: >-
      Folder management for saving generated assets — list/create/rename/move/
      delete folders, and read the account's default save folder.

paths:
  /api/v1/generate/2d:
    post:
      tags: [Generation]
      summary: Generate a 2D sprite/asset
      operationId: generate2d
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/GenerateRequest"
                - type: object
                  properties:
                    options:
                      allOf:
                        - $ref: "#/components/schemas/GenerateOptions"
                        - type: object
                          properties:
                            resolution: { type: string, example: "32x32" }
                            frames: { type: integer, minimum: 1 }
                            format: { type: string, example: png }
      responses:
        "200":
          description: Generated successfully (real or mock).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/GenerateResponseBase"
                  - type: object
                    properties:
                      output:
                        allOf:
                          - $ref: "#/components/schemas/AssetOutput"
                          - type: object
                            properties:
                              width: { type: integer }
                              height: { type: integer }
                              frames: { type: integer }
                      outputUrl:
                        type: string
                        format: uri
                        description: Convenience duplicate of output.url.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientShards" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/ProviderError" }

  /api/v1/generate/3d:
    post:
      tags: [Generation]
      summary: Generate a 3D model (consider /api/v1/jobs instead — this is the slowest category)
      operationId: generate3d
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/GenerateRequest"
                - type: object
                  properties:
                    options:
                      allOf:
                        - $ref: "#/components/schemas/GenerateOptions"
                        - type: object
                          properties:
                            format: { type: string, example: glb }
                            polyCount: { type: integer }
                            textured: { type: boolean }
                            rigged: { type: boolean }
                            mode:
                              type: string
                              enum: [text-to-3d, image-to-3d]
                              description: image-to-3d requires inputFiles[0].url.
      responses:
        "200":
          description: Generated successfully (real via Meshy/Tripo, or mock).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/GenerateResponseBase"
                  - type: object
                    properties:
                      output:
                        allOf:
                          - $ref: "#/components/schemas/AssetOutput"
                          - type: object
                            properties:
                              polyCount: { type: integer }
                              textured: { type: boolean }
                              rigged: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientShards" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/ProviderError" }

  /api/v1/generate/image:
    post:
      tags: [Generation]
      summary: Generate a high-resolution image
      operationId: generateImage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/GenerateRequest"
                - type: object
                  properties:
                    options:
                      allOf:
                        - $ref: "#/components/schemas/GenerateOptions"
                        - type: object
                          properties:
                            model: { type: string }
                            resolution: { type: string, example: "1024x1024" }
      responses:
        "200":
          description: Generated successfully (real or mock).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/GenerateResponseBase"
                  - type: object
                    properties:
                      output:
                        allOf:
                          - $ref: "#/components/schemas/AssetOutput"
                          - type: object
                            properties:
                              width: { type: integer }
                              height: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientShards" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/ProviderError" }

  /api/v1/generate/voice:
    post:
      tags: [Generation]
      summary: Generate speech from text (also accepts a reference-audio inputFiles entry for voice cloning, provider-dependent)
      operationId: generateVoice
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text:
                  type: string
                  description: Alias for prompt — either field works, at least one is required.
                prompt: { type: string }
                tool: { type: string }
                routing: { $ref: "#/components/schemas/Routing" }
                inputFiles:
                  type: array
                  items: { $ref: "#/components/schemas/InputFile" }
                options:
                  allOf:
                    - $ref: "#/components/schemas/GenerateOptions"
                    - type: object
                      properties:
                        voice_id: { type: string }
                        model: { type: string }
      responses:
        "200":
          description: Generated successfully (real or mock).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/GenerateResponseBase"
                  - type: object
                    properties:
                      output:
                        allOf:
                          - $ref: "#/components/schemas/AssetOutput"
                          - type: object
                            properties:
                              durationMs: { type: integer }
                              characters: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientShards" }
        "422":
          description: Neither text nor prompt was provided, or another validation error.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/ProviderError" }

  /api/v1/generate/music:
    post:
      tags: [Generation]
      summary: Generate music (mock only today — MUBERT_API_KEY is not currently configured)
      operationId: generateMusic
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/GenerateRequest"
                - type: object
                  properties:
                    options:
                      allOf:
                        - $ref: "#/components/schemas/GenerateOptions"
                        - type: object
                          properties:
                            duration: { type: integer, description: Seconds, default: 30 }
      responses:
        "200":
          description: Generated successfully. Currently always mock (no MUBERT_API_KEY configured) — confirm before relying on real output.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/GenerateResponseBase"
                  - type: object
                    properties:
                      output:
                        allOf:
                          - $ref: "#/components/schemas/AssetOutput"
                          - type: object
                            properties:
                              durationMs: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientShards" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/ProviderError" }

  /api/v1/generate/vfx:
    post:
      tags: [Generation]
      summary: Generate a VFX/video clip
      operationId: generateVfx
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/GenerateRequest"
                - type: object
                  properties:
                    options:
                      allOf:
                        - $ref: "#/components/schemas/GenerateOptions"
                        - type: object
                          properties:
                            duration: { type: integer, description: Seconds, default: 5 }
                            resolution: { type: string, example: 720p }
      responses:
        "200":
          description: Generated successfully (real via Luma, or mock).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/GenerateResponseBase"
                  - type: object
                    properties:
                      output:
                        allOf:
                          - $ref: "#/components/schemas/AssetOutput"
                          - type: object
                            properties:
                              durationMs: { type: integer }
                      outputUrl: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientShards" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/ProviderError" }

  /api/v1/generate/editing:
    post:
      tags: [Generation]
      summary: Edit an existing image — upscale, inpaint, outpaint, or remove background (requires inputFiles)
      operationId: generateEditing
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/GenerateRequest"
                - type: object
                  required: [inputFiles]
                  properties:
                    tool:
                      type: string
                      enum: [stability-upscale, stability-inpaint, stability-outpaint, stability-remove-bg]
                      description: Explicit mode selection is recommended for this category.
                    options:
                      allOf:
                        - $ref: "#/components/schemas/GenerateOptions"
                        - type: object
                          properties:
                            mask_url: { type: string, format: uri, description: Required for inpaint. }
                            left: { type: integer }
                            right: { type: integer }
                            up: { type: integer }
                            down: { type: integer }
      responses:
        "200":
          description: Generated successfully (real, via Stability).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/GenerateResponseBase"
                  - type: object
                    properties:
                      output:
                        allOf:
                          - $ref: "#/components/schemas/AssetOutput"
                          - type: object
                            properties:
                              width: { type: integer }
                              height: { type: integer }
                      outputUrl: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientShards" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/ProviderError" }

  /api/v1/jobs:
    post:
      tags: [Jobs]
      summary: Submit an async generation job (preferred for 3D)
      operationId: createJob
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [category, prompt]
              properties:
                category:
                  $ref: "#/components/schemas/Category"
                prompt: { type: string }
                tool: { type: string }
                routing: { $ref: "#/components/schemas/Routing" }
                options: { $ref: "#/components/schemas/GenerateOptions" }
                inputFiles:
                  type: array
                  items: { $ref: "#/components/schemas/InputFile" }
                saveTo:
                  type: object
                  description: >-
                    Optional — save the generated output as a project asset.
                    Get a folderId from GET /api/v1/projects (its
                    defaultSaveFolderId, or any id in folders[]) or create one
                    via POST /api/v1/projects/folders. If folderId is omitted
                    or empty, saveTo is silently ignored: the job still
                    succeeds and returns output normally, nothing is saved,
                    and no error is raised.
                  properties:
                    folderId: { type: string }
                    name:
                      type: string
                      description: Defaults to "{tool}-{jobId}.{format}" if omitted.
                source:
                  type: string
                  enum: [nexus_ui, studio, mcp, minds, api]
                  default: api
                  description: Self-reported, for observability only — not security-relevant.
      responses:
        "202":
          description: Job queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobId: { type: string, format: uuid }
                  status: { type: string, enum: [queued] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientShards" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: Failed to enqueue (shard hold released automatically).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: Async jobs are not configured in this environment (QSTASH_TOKEN missing).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    get:
      tags: [Jobs]
      summary: List your recent jobs (most recent 50)
      operationId: listJobs
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/jobs/{jobId}:
    get:
      tags: [Jobs]
      summary: Get a job's status and (once succeeded) output
      operationId: getJob
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Job not found, or not owned by this key's account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/credits:
    get:
      tags: [Account]
      summary: Get the current shard balance
      operationId: getCredits
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  balance: { type: number, description: Shards. }
                  currency:
                    type: string
                    example: USD
                    description: Legacy field, always "USD". The balance is in Shards.
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/projects:
    get:
      tags: [Projects]
      summary: List your project folders and assets
      operationId: listProjects
      parameters:
        - name: folderId
          in: query
          required: false
          schema: { type: string }
          description: Filter assets to one folder. Omit to get every asset on the account.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  folders:
                    type: array
                    items: { $ref: "#/components/schemas/Folder" }
                  assets:
                    type: array
                    items: { $ref: "#/components/schemas/Asset" }
                  defaultSaveFolderId:
                    type: [string, "null"]
                    description: >-
                      The account's default save folder, if one is set — a
                      ready-made folderId for saveTo that needs no lookup.
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/projects/folders:
    post:
      tags: [Projects]
      summary: Create a project folder
      operationId: createFolder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                parentId:
                  type: [string, "null"]
                  description: Nest under an existing folder. Omit or null for a top-level folder.
                kind:
                  type: string
                  enum: [project, folder]
                  default: folder
                engine:
                  type: [string, "null"]
                  enum: [godot, unreal, null]
                  description: Only meaningful when kind is "project".
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  folder: { $ref: "#/components/schemas/Folder" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /api/v1/projects/folders/{folderId}:
    patch:
      tags: [Projects]
      summary: Rename or move a folder
      operationId: updateFolder
      parameters:
        - name: folderId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                parentId:
                  type: [string, "null"]
                  description: Set to move the folder; null moves it to the top level.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  folder: { $ref: "#/components/schemas/Folder" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Projects]
      summary: Delete a folder
      operationId: deleteFolder
      parameters:
        - name: folderId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "gr_live_... or gr_test_..."
      description: >-
        Bearer-only. No alternate header scheme is supported. gr_test_* keys
        behave identically to gr_live_* (same base URL, same schemas, same
        real-or-mock provider dispatch), they're just an independently
        revocable credential minted the same way, for sandbox use.

  parameters: {}

  responses:
    Unauthorized:
      description: Missing, malformed, or unrecognized Authorization header.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example: { error: { code: unauthorized, message: "API key not recognized" } }
    ValidationError:
      description: Request body failed validation (missing/malformed required field).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    InsufficientShards:
      description: The account behind this key doesn't have enough balance for this generation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example: { error: { code: insufficient_shards, message: "Insufficient shard balance" } }
    RateLimited:
      description: Too many requests for this key's configured rate limit (default 120/min).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example: { error: { code: rate_limited, message: "Rate limit exceeded" } }
    ProviderError:
      description: >-
        No compatible tool was found for this category (no_tool_available), or
        the upstream provider call failed (provider_error). The shard hold is
        released automatically either way — you were not charged.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }

  schemas:
    Category:
      type: string
      enum: [2d, 3d, voice, music, image, vfx, editing]
      description: npc/video/skybox exist internally but have no generate/* endpoint or configured tool yet.

    Routing:
      type: string
      enum: [cheapest, best, fastest]
      default: best
      description: Tool auto-selection preference when tool is not explicitly set.

    InputFile:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }
        name: { type: string, description: "Defaults to \"input\" if omitted." }
        mimeType: { type: string, description: "Defaults to application/octet-stream if omitted." }

    GenerateOptions:
      type: object
      description: Category-specific — see the field list on each endpoint. Unrecognized fields are ignored, not rejected.
      additionalProperties: true

    GenerateRequest:
      type: object
      required: [prompt]
      properties:
        prompt: { type: string }
        tool:
          type: string
          description: Explicit provider/tool id, e.g. "pixellab", "leonardo". Omit to auto-select via routing.
        routing: { $ref: "#/components/schemas/Routing" }
        inputFiles:
          type: array
          items: { $ref: "#/components/schemas/InputFile" }
        options: { $ref: "#/components/schemas/GenerateOptions" }

    AssetOutput:
      type: object
      properties:
        url: { type: string, format: uri }
        format: { type: string, example: png }

    GenerateResponseBase:
      type: object
      properties:
        id:
          type: string
          description: Same value as the X-Request-Id response header.
        tool: { type: string, description: The tool id actually used. }
        provider: { type: string }
        shardCost: { type: number }
        latencyMs: { type: integer }
        routing: { $ref: "#/components/schemas/Routing" }

    JobStatus:
      type: string
      enum: [queued, running, succeeded, failed]

    Job:
      type: object
      properties:
        id: { type: string, format: uuid }
        userId: { type: string }
        source: { type: string, enum: [nexus_ui, studio, mcp, minds, api] }
        category: { $ref: "#/components/schemas/Category" }
        toolId: { type: [string, "null"] }
        routing: { $ref: "#/components/schemas/Routing" }
        input:
          type: object
          description: Echo of the original request (prompt, options, tool, saveTo, inputFiles).
        status: { $ref: "#/components/schemas/JobStatus" }
        output:
          description: >-
            Present once status is succeeded. Not the generate/* response
            body: tool is an object and the file URL is a flat outputUrl.
          oneOf:
            - $ref: "#/components/schemas/JobOutput"
            - type: "null"
        error: { type: [string, "null"], description: Present when status is failed. }
        shardCost: { type: [number, "null"] }
        createdAt: { type: string, format: date-time }
        startedAt: { type: [string, "null"], format: date-time }
        completedAt: { type: [string, "null"], format: date-time }

    JobOutput:
      type: object
      required: [tool, category, outputUrl, format, shardCost]
      properties:
        tool:
          type: object
          description: The tool actually used.
          properties:
            id: { type: string, example: pixellab }
            name: { type: string }
            provider: { type: string }
        category: { $ref: "#/components/schemas/Category" }
        outputUrl: { type: string, format: uri }
        format: { type: string, example: png }
        mimeType: { type: string, example: image/png }
        width: { type: integer }
        height: { type: integer }
        frames: { type: integer }
        durationMs: { type: integer, description: Audio and video only. }
        characters: { type: integer, description: Voice only. }
        shardCost: { type: number }
        latencyMs: { type: integer }
        mock: { type: boolean, description: True when no real provider was available and the output is a placeholder. }
        savedAs:
          type: object
          description: Present when saveTo named a folder.
          properties:
            assetId: { type: string }
            name: { type: string }
      additionalProperties: true

    Folder:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        parentId: { type: [string, "null"] }
        kind: { type: string, enum: [project, folder] }
        engine: { type: [string, "null"], enum: [godot, unreal, null] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Asset:
      type: object
      description: A generated (or uploaded) file saved into a folder via saveTo.
      properties:
        id: { type: string }
        folderId: { type: string }
        name: { type: string }
        type: { type: string, enum: [2d, 3d, voice, music, image, vfx, other] }
        source: { type: string, enum: [generated, upload] }
        toolId: { type: string }
        toolName: { type: string }
        provider: { type: string }
        outputUrl: { type: string, format: uri }
        format: { type: string }
        prompt: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - validation_error
                - no_tool_available
                - tool_not_found
                - provider_error
                - insufficient_shards
                - rate_limited
                - jobs_not_configured
                - enqueue_failed
                - job_not_found
                - internal_error
                - server_error
            message: { type: string }
