openapi: 3.1.0
info:
  title: GetFacade agent API
  version: "1.0.0"
  summary: The endpoints an AI agent uses to get a house exterior designed, priced and documented, without a human at the screen.
  description: |
    Each design is worked out for the country the building stands in: materials that are
    applicable there, manufacturer products that are actually sold there, and the technical
    build-up behind the surface. A render shows that design on the photo of the house.

    The estimate and the album come from the same design. The estimate prices it line by line,
    in materials and labour, at what those materials cost in that country. The album documents
    it for the crew that builds it, with the build-up of the facade, safety notes and the norms
    behind them.

    **Results are permanent public links.** A finished render (`file_url`) and a finished album
    (`public_url`) are served from stable URLs with no signature and no expiry, so one can be
    handed to a person as the answer to "show me the result". The honest half of that: an
    unsigned link asks nobody for permission, so it keeps working for whoever it is forwarded
    to and cannot be recalled. `GET /renders/{render}/download` is a different thing — a
    short-lived signed URL with a filename, for saving the file rather than for sharing it.

    This is **not a second API**. Every path below is an existing GetFacade endpoint, the same
    one the iOS, Android and web apps call; an agent key only narrows who may call it and adds
    an admission gate in front of the paid ones. See `docs/agent-api/PLAN.md`.

    Agent access is paid and carries no trial credits: the account needs `api`-scope credits,
    and every key has a hard spend cap. A subscription is not a way in — every key pays in
    credits. A refused call answers `402` with a machine-readable `code` and a human-readable
    `detail` written by the API — display that text as it is, never compose your own.

    A key issued with purchasing enabled can refill its own wallet through `/tokens/purchase`,
    and only up to what it may still spend: a purchase never lifts the key's spend cap.

    **Every paid call is named, and a named call is never performed twice.** A render, estimate,
    album, upscale or token purchase made with an agent key carries an `Idempotency-Key` header,
    and a repeat of that same call is answered with the original response instead of queueing
    (and charging for) a second job. Without the header such a call answers 422
    `IDEMPOTENCY_KEY_REQUIRED` before anything is queued.

    Nothing else catches a repeat: with `seed` omitted the server rolls a fresh one per request,
    so two byte-identical bodies are two different renders on purpose. Only the caller knows
    whether the second call is a retry or another variant, which is why the header is what says
    so. Use one value per logical order, keep it while retrying, and generate a new one for the
    next order. Values are remembered for 24 hours, per account. `@getfacade/mcp` does all of
    this on its own.

    Long work (renders, estimates, albums) is asynchronous: the create call returns a job id
    immediately, and the job is polled. Photo validation is announced over a websocket the agent
    does not have, so it is polled too.
  license:
    name: Proprietary
    url: https://getfacade.ai/terms
servers:
  - url: https://api.getfacade.ai/api/v1
    description: Production
tags:
  - name: Buildings
    description: Houses the agent designs. Everything else hangs off a building.
  - name: Views
    description: Exterior photos of a building, uploaded and validated before they can be rendered.
  - name: Designs
    description: A design groups a main view with the renders made from it.
  - name: Renders
    description: The design worked out on one view and shown as an image. Queued, then polled.
  - name: Estimates
    description: What the designed exterior costs, line by line, priced from the design itself.
  - name: Albums
    description: The PDF album (blueprint document). The design documented for the crew that builds it, with materials, build-up and norms.
  - name: Jobs
    description: One list of everything queued across the account.
  - name: Wallet
    description: What this key may still spend, whether the next paid call will be accepted, and how it refills.
  - name: Reference
    description: Read-only lists the render settings take ids from - palettes, styles, materials and manufacturer products.
  - name: Account
    description: The account behind the key. Its language, and the capabilities it holds.
  - name: Feedback
    description: Reporting a defect in this API back to the people who maintain it.
security:
  - agentKey: []
paths:
  /projects:
    post:
      tags: [Buildings]
      operationId: createBuilding
      summary: Create a building
      description: The name is unique within the account; a duplicate is refused with 422.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  type: object
                  required: [type, attributes]
                  properties:
                    type: { const: project }
                    attributes:
                      type: object
                      required: [name]
                      properties:
                        name: { type: string, maxLength: 50 }
                        goals: { type: string, maxLength: 10000, description: Free-form brief }
                        construction_region: { type: string, maxLength: 255 }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Resource" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /projects/{project}/angles:
    post:
      tags: [Views]
      operationId: registerView
      summary: Register a photo of the building
      description: |
        First of three legs. Returns an `upload_policy` with a presigned URL; PUT the bytes to it
        (no `Authorization` header there, the signature is the authorisation), then confirm.
        `aspect_ratio` is optional: given `width` and `height`, the server picks the nearest
        supported ratio itself.

        Use this path, not `POST /angles`: a view created outside a building cannot be made the
        main view of a design.
      parameters:
        - $ref: "#/components/parameters/project"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  type: object
                  required: [type, attributes]
                  properties:
                    type: { const: angle }
                    attributes:
                      type: object
                      required: [md5, width, height]
                      properties:
                        md5: { type: string }
                        width: { type: integer, minimum: 1 }
                        height: { type: integer, minimum: 1 }
                        aspect_ratio: { type: string, description: Optional; derived from width/height when omitted }
                        file_name: { type: string }
                        content_type: { type: string, examples: ["image/jpeg"] }
      responses:
        "201":
          description: Registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/Resource"
                      - type: object
                        properties:
                          attributes:
                            type: object
                            properties:
                              upload_policy: { $ref: "#/components/schemas/UploadPolicy" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /angles/{angle}/confirm:
    post:
      tags: [Views]
      operationId: confirmView
      summary: Confirm the uploaded bytes
      description: Third leg. Starts asynchronous validation of the photo.
      parameters:
        - $ref: "#/components/parameters/angle"
      responses:
        "200": { $ref: "#/components/responses/Document" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /angles/{angle}/validation:
    get:
      tags: [Views]
      operationId: getViewValidation
      summary: Poll the validation of a photo
      description: |
        Stop polling when `is_in_progress` is `false` — do not re-derive the set of terminal
        statuses on the client, it has grown four times. A rejected photo cannot be rendered.
      parameters:
        - $ref: "#/components/parameters/angle"
      responses:
        "200":
          description: Validation state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      type: { const: angle-validation }
                      id: { type: string }
                      attributes:
                        type: object
                        properties:
                          status: { type: string }
                          is_in_progress: { type: boolean }
                          is_valid: { type: [boolean, "null"] }
                          validation_failure_reason: { type: [string, "null"] }
                          recommendations: { type: [array, "null"], items: { type: string } }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /projects/{project}/concepts:
    get:
      tags: [Designs]
      operationId: listDesigns
      summary: List the designs of a building
      description: |
        Renders appear in `relationships` as identifiers only, without attributes: this is where
        render ids for an estimate or an album come from, not where render state is read. Poll
        `GET /renders/{render}` for state; `has_main_render` says whether the design is finished.

        `main_render_id` is the finished render of the main view — the render an estimate, an
        album or a refine starts from. It is null until one has finished, which is exactly when
        `mode: refine` would refuse it as a parent.
      parameters:
        - $ref: "#/components/parameters/project"
      responses:
        "200": { $ref: "#/components/responses/Collection" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
    post:
      tags: [Designs]
      operationId: createDesign
      summary: Create a design from a view
      parameters:
        - $ref: "#/components/parameters/project"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  type: object
                  required: [type, relationships]
                  properties:
                    type: { const: concept }
                    relationships:
                      type: object
                      required: [main_angle]
                      properties:
                        main_angle: { $ref: "#/components/schemas/ToOne" }
                        derivative_angles: { $ref: "#/components/schemas/ToMany" }
      responses:
        "201": { $ref: "#/components/responses/Document" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /concepts/{concept}/angles:
    get:
      tags: [Designs]
      operationId: listDesignViews
      summary: List the views of a design
      description: |
        A render is addressed by the design-view row, not by the view. Read it from the
        `main_angle` relationship of the created design when present, and from this list otherwise.
      parameters:
        - $ref: "#/components/parameters/concept"
      responses:
        "200": { $ref: "#/components/responses/Collection" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
  /concepts/{concept}/angles/{conceptAngle}/renders:
    post:
      tags: [Renders]
      operationId: startRender
      summary: Queue the design work on a view
      description: |
        Designs the exterior on this view and shows it on the photo: materials applicable in the
        building's country, products actually sold there, and the build-up behind the surface.
        Returns the render immediately with a non-terminal status: its `id` is the job
        id. The finished `file_url` is a permanent public link (no signature, no expiry) that
        can be handed to a person as it stands, and that keeps working for anyone it reaches.

        Colour and brand are two different fields. `colors` carries the palette: `palette:1`,
        `#RRGGBB`, and the order carries the 60/30/10 role, the first entry being the dominant
        wall colour. `brand_selections` carries real manufacturer products at whatever precision
        is known — `siding:brand:12`, `siding:line:40@double-4-dutchlap`,
        `siding:product:88@double-4-dutchlap`, `paint:brand:3`, `paint:product:412` — where the
        optional `@profile` suffix pins the siding geometry. Ten entries each.

        `seed` is optional; omit it unless reproducing an earlier render, the server generates
        and returns one.

        `render_effort` asks for a tier: `BRAINSTORM` is the quickest, `STANDARD` the middle,
        `HIGH` the slowest and most detailed. Some buildings resolve the tier on their own and
        the request never reaches the render, so the response echoes what was asked for rather
        than what ran.

        **Refining an existing design.** With `mode: refine` the render iterates on
        `parent_render_id` instead of the view photo: the instruction in
        `prompts.prompt_concept` is applied to that finished picture and everything it does not
        mention is kept. The parent must be **completed**, must belong to the same building, and
        must sit in the same view stack as the render being created (a main render refines a main
        render; a secondary view refines within its own stack) — otherwise this answers 422.

        Refining a main view does **not** overwrite the picture: the server forks a new design,
        so the design the result lands in is the one reported by
        `relationships.concept_angle.meta.concept_id` of the response, not the one in the path.
      parameters:
        - $ref: "#/components/parameters/concept"
        - $ref: "#/components/parameters/conceptAngle"
        - $ref: "#/components/parameters/idempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  type: object
                  required: [type, attributes]
                  properties:
                    type: { const: render }
                    attributes:
                      type: object
                      required: [version]
                      properties:
                        version: { const: 1 }
                        seed: { type: integer, minimum: 1, maximum: 2147483647 }
                        style_ids: { type: array, items: { type: integer } }
                        colors:
                          type: array
                          maxItems: 10
                          items: { type: string }
                          description: "Palette entries: `palette:{id}` or `#RRGGBB`. Ordered 60/30/10."
                        brand_selections:
                          type: array
                          maxItems: 10
                          items: { type: string }
                          description: "Manufacturer products: `{category}:{level}:{id}[@profile]`."
                        render_effort:
                          type: string
                          enum: [BRAINSTORM, STANDARD, HIGH]
                          description: "How hard the engine works on this picture. Ignored where the building resolves the tier itself; `HIGH` answers 422 without the entitlement."
                        prompts:
                          type: object
                          properties:
                            prompt_concept: { type: string, maxLength: 400 }
                            prompt_angle: { type: string, maxLength: 300 }
                        mode:
                          type: string
                          enum: [explore, refine]
                          description: "`explore` (default) renders from the view photo; `refine` iterates on `parent_render_id`."
                        parent_render_id:
                          type: string
                          description: "Required when `mode` is `refine`. Must be a completed render in the same building and view stack."
                        controls:
                          type: array
                          maxItems: 5
                          items:
                            type: object
                            required: [id]
                            properties:
                              id: { type: string, format: uuid }
                          description: "Sketches that steer the render. Uploading one is not part of this API yet."

      responses:
        "201":
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Render" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /renders/{render}:
    get:
      tags: [Renders]
      operationId: getRender
      summary: Poll one render
      parameters:
        - $ref: "#/components/parameters/render"
      responses:
        "200":
          description: Render state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Render" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
    delete:
      tags: [Renders]
      operationId: deleteRender
      summary: Delete one render
      description: |
        Deleting the main render of a design returns that design to draft, so it can be
        rendered again without creating a new one. Deletion is soft server-side and is undone
        by a person in the app, not through this API.
      parameters:
        - $ref: "#/components/parameters/render"
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthenticated" }
  /renders/{render}/download:
    get:
      tags: [Renders]
      operationId: downloadRender
      summary: Get a short-lived signed download URL for a finished render
      description: |
        For saving the file, not for sharing it: the URL is signed, carries a filename and
        expires in minutes. The link to give a person is the render's own `file_url`, which is
        public and permanent.
      parameters:
        - $ref: "#/components/parameters/render"
      responses:
        "200": { $ref: "#/components/responses/Document" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /projects/{project}/concepts/{concept}:
    get:
      tags: [Designs]
      operationId: showDesign
      summary: One design of a building
      description: |
        The design itself, including `main_render_id` — the finished main render, and the id a
        refine iterates on. It stays null until a main render finishes, which is exactly the
        eligibility rule refine enforces.
      parameters:
        - $ref: "#/components/parameters/project"
        - $ref: "#/components/parameters/concept"
      responses:
        "200": { $ref: "#/components/responses/Document" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
    delete:
      tags: [Designs]
      operationId: deleteDesign
      summary: Delete one design with the renders under it
      description: |
        Deletion is soft server-side and is undone by a person in the app, not through this API.
      parameters:
        - $ref: "#/components/parameters/project"
        - $ref: "#/components/parameters/concept"
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  /projects/{project}/concepts/{concept}/estimates:
    post:
      tags: [Estimates]
      operationId: orderEstimate
      summary: Price the designed exterior
      description: |
        Prices the design line by line, in materials and labour, at what the specified materials
        cost in the building's country.
        `selected_renders` must name at least one finished render of this design. Currency and
        measurement system default to the country of the building: omit them unless a specific
        one was asked for.
      parameters:
        - $ref: "#/components/parameters/project"
        - $ref: "#/components/parameters/concept"
        - $ref: "#/components/parameters/idempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  type: object
                  required: [type, attributes]
                  properties:
                    type: { const: estimate }
                    attributes:
                      type: object
                      required: [selected_renders]
                      properties:
                        selected_renders: { type: array, minItems: 1, items: { type: string } }
                        currency: { type: string, minLength: 3, maxLength: 3 }
                        measurement_system: { type: string, enum: [metric, imperial] }
                        special_requirements: { type: string, maxLength: 3000 }
      responses:
        "202": { $ref: "#/components/responses/Document" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /estimates/{estimate}:
    get:
      tags: [Estimates]
      operationId: getEstimate
      summary: Read one estimate
      description: >-
        The estimate with its totals, assumptions and every line in `included`.
        This is where a line id comes from, and the only place the content of an
        estimate is readable; a finished estimate reads `ready`,
        and a failed one carries `failure_reason` saying why.
      parameters:
        - $ref: "#/components/parameters/estimate"
      responses:
        "200": { $ref: "#/components/responses/Document" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
  /estimates/{estimate}/regenerate:
    post:
      tags: [Estimates]
      operationId: regenerateEstimate
      summary: Price the design again
      description: |
        A fresh generation of the same estimate from its renders, at the current prices. Costs
        the same as ordering one. An estimate carrying manual line edits answers 409
        `COST_ESTIMATE_OVERWRITE_REQUIRED` unless `confirm_overwrite` is true, so a stray call
        never discards hand-tuned numbers.
      parameters:
        - $ref: "#/components/parameters/estimate"
        - $ref: "#/components/parameters/idempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: object
                  properties:
                    type: { const: estimate }
                    attributes:
                      type: object
                      properties:
                        confirm_overwrite:
                          type: boolean
                          description: Required to overwrite an estimate with manual line edits.
      responses:
        "202": { $ref: "#/components/responses/Document" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409":
          description: |
            `COST_ESTIMATE_OVERWRITE_REQUIRED` — the estimate has manual edits; repeat with
            `confirm_overwrite: true` to discard them. `IDEMPOTENCY_IN_PROGRESS` — the first call
            under this `Idempotency-Key` has not answered yet.
          content:
            application/vnd.api+json:
              schema: { $ref: "#/components/schemas/Errors" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /concepts/{concept}/album:
    get:
      tags: [Albums]
      operationId: listAlbums
      summary: List the albums of a design
      description: Albums have no single-album endpoint; polling one means finding its id in this list.
      parameters:
        - $ref: "#/components/parameters/concept"
      responses:
        "200": { $ref: "#/components/responses/Collection" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
  /concepts/{concept}/album/generate:
    post:
      tags: [Albums]
      operationId: orderAlbum
      summary: Document the designed exterior as a PDF album
      description: |
        The design documented for the crew that builds it: materials, the build-up of the
        facade, safety notes and the regulatory references behind them. Requires a finished main
        render on the design;
        without one the API refuses with 422 and an explanation. The finished album's
        `public_url` is a permanent public link that can be handed to a person as it stands.
      parameters:
        - $ref: "#/components/parameters/concept"
        - $ref: "#/components/parameters/idempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  type: object
                  required: [type, attributes]
                  properties:
                    type: { const: album }
                    attributes:
                      type: object
                      required: [selected_renders]
                      properties:
                        selected_renders: { type: array, minItems: 1, items: { type: string } }
                        language: { type: string, description: Defaults to the account language }
                        include_blueprints: { type: boolean }
                        include_estimate: { type: boolean }
                        requirements: { type: string, maxLength: 2000 }
      responses:
        "202": { $ref: "#/components/responses/Document" }
        "200":
          description: >-
            An album with these exact settings already exists and is returned instead of a second
            one: `duplicate_exists` is true on it. Send `force_regenerate` to build a new one.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Resource" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /renders/{render}/upscale:
    post:
      tags: [Renders]
      operationId: upscaleRender
      summary: Enlarge a finished render
      description: |
        Costs tokens and is asynchronous, exactly like a render: poll the returned job. A render
        that is not finished, or already upscaled, is refused with the API's own explanation.
      parameters:
        - $ref: "#/components/parameters/render"
        - $ref: "#/components/parameters/idempotencyKey"
      responses:
        "202":
          description: Upscale queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Render" }
        "200":
          description: The render is already upscaled; its file URL is returned and nothing was charged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Render" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409":
          description: |
            An upscale of this render is already running (its job id is in the body), or
            `IDEMPOTENCY_IN_PROGRESS` — the first call under this `Idempotency-Key` has not
            answered yet. Both mean the same thing in practice: poll, do not order again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Errors" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /projects/{project}:
    delete:
      tags: [Buildings]
      operationId: deleteBuilding
      summary: Delete a building with everything under it
      description: |
        Photos, designs, renders, estimates and albums go with it. Deletion is soft
        server-side and is undone by a person in the app. Tokens already spent are not refunded.
      parameters:
        - $ref: "#/components/parameters/project"
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  /estimates/{estimate}/items:
    post:
      tags: [Estimates]
      operationId: addEstimateLine
      summary: Add one line to an estimate
      description: |
        `unit` must be a display code of the estimate's OWN measurement system — read an
        existing line rather than guessing. Totals are recomputed server-side.
      parameters:
        - $ref: "#/components/parameters/estimate"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: object
                  required: [type, attributes]
                  properties:
                    type: { const: estimate_item }
                    attributes:
                      type: object
                      required: [section, name, quantity, unit_price]
                      properties:
                        section: { type: string, enum: [materials, labor] }
                        name: { type: string, maxLength: 255 }
                        quantity: { type: number }
                        unit_price: { type: number }
                        unit: { type: string }
                        category: { type: string, maxLength: 100 }
      responses:
        "201": { $ref: "#/components/responses/Document" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /estimates/{estimate}/items/{item}:
    patch:
      tags: [Estimates]
      operationId: updateEstimateLine
      summary: Edit one line of an estimate
      description: Only the fields sent are changed. Totals are recomputed server-side.
      parameters:
        - $ref: "#/components/parameters/estimate"
        - $ref: "#/components/parameters/estimateItem"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: object
                  required: [type, id]
                  properties:
                    type: { const: estimate_item }
                    id: { type: string }
                    attributes:
                      type: object
                      properties:
                        section: { type: string, enum: [materials, labor] }
                        name: { type: string, maxLength: 255 }
                        quantity: { type: number }
                        unit_price: { type: number }
                        unit: { type: string }
                        category: { type: string, maxLength: 100 }
      responses:
        "200": { $ref: "#/components/responses/Document" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Estimates]
      operationId: deleteEstimateLine
      summary: Remove one line from an estimate
      parameters:
        - $ref: "#/components/parameters/estimate"
        - $ref: "#/components/parameters/estimateItem"
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  # Reference data. Read-only lists an agent reads the ids out of: the colour
  # grammar of a render takes them by id, and guessing one is not an option.
  /catalog/brands:
    get: { tags: [Reference], operationId: listBrands, summary: Manufacturer brands, responses: { "200": { $ref: "#/components/responses/Collection" } } }
  /catalog/categories:
    get: { tags: [Reference], operationId: listProductCategories, summary: Product categories, responses: { "200": { $ref: "#/components/responses/Collection" } } }
  /catalog/lines:
    get: { tags: [Reference], operationId: listProductLines, summary: Product lines of a brand, responses: { "200": { $ref: "#/components/responses/Collection" } } }
  /catalog/products:
    get: { tags: [Reference], operationId: listProducts, summary: Manufacturer products, with the ids `brand_selections` takes, responses: { "200": { $ref: "#/components/responses/Collection" } } }
  /palettes:
    get: { tags: [Reference], operationId: listPalettes, summary: Colour palettes, with the ids `colors` takes as `palette:N`, responses: { "200": { $ref: "#/components/responses/Collection" } } }
  /styles:
    get: { tags: [Reference], operationId: listStyles, summary: Architectural styles, with the ids `style_ids` takes, responses: { "200": { $ref: "#/components/responses/Collection" } } }
  /materials:
    get: { tags: [Reference], operationId: listMaterials, summary: Facade materials, responses: { "200": { $ref: "#/components/responses/Collection" } } }
  /output-styles:
    get: { tags: [Reference], operationId: listOutputStyles, summary: Output styles of a render, responses: { "200": { $ref: "#/components/responses/Collection" } } }
  /currencies:
    get: { tags: [Reference], operationId: listCurrencies, summary: Currencies an estimate can be priced in, responses: { "200": { $ref: "#/components/responses/Collection" } } }
  /renders/token-prices:
    get: { tags: [Reference], operationId: listRenderTokenPrices, summary: Indicative token price per render effort, responses: { "200": { $ref: "#/components/responses/Document" } } }
  /queue/status:
    get: { tags: [Reference], operationId: showQueueStatus, summary: How busy the render queue is, responses: { "200": { $ref: "#/components/responses/Document" } } }

  /user:
    get:
      tags: [Account]
      operationId: showAccount
      summary: The account behind this key
      description: |
        Its name, its language and the capabilities it holds. The language is the album's
        default, and the capability flags say what a generation may ask for.
      responses:
        "200": { $ref: "#/components/responses/Document" }
        "401": { $ref: "#/components/responses/Unauthenticated" }

  /tokens/packages:
    get:
      tags: [Wallet]
      operationId: listTokenPackages
      summary: The token packages this account can buy
      responses:
        "200": { $ref: "#/components/responses/Collection" }

  /tokens/purchase:
    post:
      tags: [Wallet]
      operationId: buyTokens
      summary: Refill this key's wallet
      description: |
        Available only to a key issued with purchasing enabled, and only up to what the key may
        still spend (cap − spent − current balance): a purchase can never lift the key's own
        spend cap. Raising the cap and enabling purchasing are human actions.

        `status: charged` means the payment provider accepted the collection from a saved
        payment method — not that the tokens have landed; they are credited when the payment is
        confirmed, so poll `/tokens/balance`. `status: requires_human` means no saved payment
        method was available: nothing was charged and `checkout_url` is for a person to open.

        A human session is refused here with `AGENT_KEY_REQUIRED` — the apps have a real
        checkout with card entry and 3-D Secure.

        Named like every other paid call: this is the one endpoint where software starts a
        charge, so a call that times out after the provider took it comes back as the same
        purchase rather than a second one.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: object
                  required: [type, attributes]
                  properties:
                    type: { const: agent_token_purchase }
                    attributes:
                      type: object
                      required: [package]
                      properties:
                        package:
                          type: string
                          description: Package id from /tokens/packages, e.g. "600".
      responses:
        "202":
          description: Purchase started
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      type: { const: agent_token_purchase }
                      id: { type: string }
                      attributes:
                        type: object
                        properties:
                          status: { type: string, enum: [charged, requires_human] }
                          transaction_id: { type: string }
                          tokens: { type: number }
                          checkout_url: { type: [string, "null"] }
                          detail: { type: string }
        "403": { $ref: "#/components/responses/PurchaseRefused" }
        "404": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /history:
    get:
      tags: [Jobs]
      operationId: listJobs
      summary: Recent jobs across the account
      description: Renders, estimates and albums in one list, unfinished first. Use it to catch up after a restart.
      parameters:
        - name: filter[type]
          in: query
          schema: { type: string, enum: [jobs, concepts], default: jobs }
        - name: filter[subtype]
          in: query
          schema: { type: string, enum: [render, estimate, album] }
        - name: page[size]
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200": { $ref: "#/components/responses/Collection" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
  /tokens/balance:
    get:
      tags: [Wallet]
      operationId: getBalance
      summary: Wallet, key cap and admission
      description: |
        Called with an agent key, the document carries an extra `agent` block: the `api`-scope
        balance, the cap of this key, and `is_admissible` — the server's own answer to "will the
        next paid call be accepted". Read that flag instead of comparing the numbers yourself.
        On a human session the `agent` key is absent from `attributes` altogether, not `null`.
      responses:
        "200":
          description: Balance
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      type: { type: string }
                      id: { type: string }
                      attributes:
                        type: object
                        properties:
                          current_balance: { type: number, description: "The app wallet, not the agent one" }
                          is_negative:
                            type: boolean
                            description: Server-authoritative debt flag; never re-derive it from the number.
                          agent:
                            allOf:
                              - $ref: "#/components/schemas/AgentWallet"
                            description: Present only for agent keys; omitted entirely for human sessions.
        "401": { $ref: "#/components/responses/Unauthenticated" }
  /feedback:
    post:
      tags: [Feedback]
      operationId: reportProblem
      summary: Report a problem with this API
      description: |
        Something wrong with the API itself: a field documented here but never sent, a refusal
        whose wording leaves no way forward, a call that only works on the second try, a result
        that does not match what was asked for.

        Free, and accepted on an empty wallet, so the refusal that just happened can be reported
        while it is still in hand. Rate-limited to 10 an hour. Nobody replies through this
        endpoint; what comes back is a reference to quote. Questions about an account or a charge
        need a person and belong on the support form instead.

        Identity is taken from the key, so no name or address is asked for.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: object
                  properties:
                    type: { const: feedback }
                    attributes:
                      type: object
                      required: [message]
                      properties:
                        message:
                          type: string
                          minLength: 10
                          maxLength: 5000
                          description: What was attempted, what was expected, what happened instead.
                        category:
                          type: string
                          enum: [bug_report, feature_request, technical_support]
                          default: bug_report
                        context:
                          type: object
                          description: Whatever identifies the case. Every field optional.
                          properties:
                            tool: { type: string, maxLength: 100 }
                            endpoint: { type: string, maxLength: 255 }
                            status_code: { type: integer, minimum: 100, maximum: 599 }
                            job_id: { type: string, maxLength: 64 }
                            expected: { type: string, maxLength: 1000 }
                            actual: { type: string, maxLength: 1000 }
      responses:
        "201":
          description: Report recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      type: { const: feedback }
                      id: { type: string }
                      attributes:
                        type: object
                        properties:
                          reference: { type: string, description: Quote this when following up. }
                          message: { type: string }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
components:
  securitySchemes:
    agentKey:
      type: http
      scheme: bearer
      description: |
        An agent key, issued by its owner at app.getfacade.ai → Account → Settings → API and shown
        exactly once. Every key carries a hard spend cap and can be revoked. Keys cannot issue
        keys: key management is a human action and answers 403 to an agent key.
  parameters:
    project:
      name: project
      in: path
      required: true
      schema: { type: string }
    angle:
      name: angle
      in: path
      required: true
      schema: { type: string }
    concept:
      name: concept
      in: path
      required: true
      schema: { type: string }
    conceptAngle:
      name: conceptAngle
      in: path
      required: true
      schema: { type: string }
    render:
      name: render
      in: path
      required: true
      schema: { type: string }
    estimate:
      name: estimate
      in: path
      required: true
      schema: { type: string }
    estimateItem:
      name: item
      in: path
      required: true
      schema: { type: string }
    idempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Names this order so a retry of it is answered rather than performed again. **Required on
        every paid call made with an agent key**: without it the call answers 422
        `IDEMPOTENCY_KEY_REQUIRED` before anything is queued. (Optional for an app session, which
        is the only other caller of these paths.)

        Any value of 8 to 191 characters, unique per account; a UUID per order is the usual
        choice. A repeat carrying the same value and the same body gets the original status and
        body back, with `Idempotent-Replay: true` on the response. The same value with a
        DIFFERENT body answers 422 `IDEMPOTENCY_KEY_REUSED`. A refusal (any 4xx) releases the
        value, so the same one can be sent again once the reason is fixed. Remembered for 24
        hours.

        A new order needs a new value. Two identical calls under two values are two designs,
        which is what exploring variants looks like on the wire.
      schema: { type: string, minLength: 8, maxLength: 191 }
  responses:
    Document:
      description: A JSON:API document
      content:
        application/json:
          schema:
            type: object
            properties:
              data: { $ref: "#/components/schemas/Resource" }
    Collection:
      description: A JSON:API collection
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: array
                items: { $ref: "#/components/schemas/Resource" }
              meta: { type: object }
    PaymentRequired:
      description: |
        Admission refused before any work was queued. `AGENT_KEY_CAP_REACHED` — this key has
        spent its cap, issue a new one or raise it. `AGENT_CREDITS_EXHAUSTED` — the account has
        no `api`-scope credits left. Neither is retryable: back off and report the `detail` text
        as it stands.
      content:
        application/vnd.api+json:
          schema: { $ref: "#/components/schemas/Errors" }
    PurchaseRefused:
      description: |
        The purchase was refused before any money moved. `AGENT_PURCHASE_NOT_ALLOWED` — the key
        was issued without permission to buy. `AGENT_PURCHASE_EXCEEDS_CAP` — the purchase would
        take the key past its spend cap; `meta.headroom` says how much it may still buy.
        `AGENT_KEY_REQUIRED` — a human session; buy in the app instead. None is retryable: each
        one is unblocked by a person, not by waiting.
      content:
        application/vnd.api+json:
          schema: { $ref: "#/components/schemas/Errors" }
    IdempotencyConflict:
      description: |
        `IDEMPOTENCY_IN_PROGRESS` — the first call carrying this `Idempotency-Key` has not
        answered yet. Retryable, and the only refusal here that is: wait and send the same call
        again, or read the job list. Do not re-send it under a new key — that is the second
        charge this header exists to prevent.
      content:
        application/vnd.api+json:
          schema: { $ref: "#/components/schemas/Errors" }
    ValidationFailed:
      description: |
        The request was understood and refused. The message is the API's own text; show it
        unchanged. `IDEMPOTENCY_KEY_REQUIRED` — a paid call from an agent key with no
        `Idempotency-Key` header; nothing was queued, send it again with one.
        `IDEMPOTENCY_KEY_REUSED` — that header value already names a different request; a new
        order needs a new value.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Errors" }
    Unauthenticated:
      description: The key is missing, revoked or expired. Re-issuing a key is a human action.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Errors" }
    TooManyRequests:
      description: |
        Rate limited on this key's own bucket, separate from the human sessions of the same
        account. Refusal is deliberately cheap: back off, do not retry in a tight loop.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Errors" }
  schemas:
    Resource:
      type: object
      description: JSON:API resource. `id` is a top-level field, never inside `attributes`.
      required: [type, id]
      properties:
        type: { type: string }
        id: { type: string }
        attributes: { type: object }
        relationships: { type: object }
    ToOne:
      type: object
      properties:
        data:
          type: object
          required: [type, id]
          properties:
            type: { type: string }
            id: { type: string }
    ToMany:
      type: object
      properties:
        data:
          type: array
          items:
            type: object
            required: [type, id]
            properties:
              type: { type: string }
              id: { type: string }
    UploadPolicy:
      type: object
      description: Presigned upload target. When `skip_upload` is true the bytes are already stored and the PUT is skipped.
      properties:
        url: { type: string, format: uri }
        method: { type: string, default: PUT }
        headers: { type: object, additionalProperties: { type: string } }
        skip_upload: { type: boolean }
    Render:
      allOf:
        - $ref: "#/components/schemas/Resource"
        - type: object
          properties:
            attributes:
              type: object
              properties:
                status: { type: string }
                started_at: { type: [string, "null"], format: date-time }
                typical_duration:
                  type: [integer, "null"]
                  description: Seconds this render usually takes end to end. Use it to pace polling.
                file_url:
                  type: [string, "null"]
                  description: Permanent public link to the finished image. No signature, no expiry.
                error_message:
                  type: [string, "null"]
                  description: Why the render failed, in the account's language. Present only on a failed render.
                error_code:
                  type: [string, "null"]
                  description: Stable machine code for the failure (provider_error, timeout, style_transfer_error, ...).
                mode: { type: string, enum: [explore, refine] }
                is_refine: { type: boolean }
                settings:
                  type: object
                  properties:
                    seed: { type: integer }
            relationships:
              type: object
              properties:
                concept_angle:
                  type: object
                  properties:
                    data: { $ref: "#/components/schemas/ToOne" }
                    meta:
                      type: object
                      description: The design this render landed in — a refine of a main view forks a new one.
                      properties:
                        concept_id: { type: string }
                        project_id: { type: string }
                parent_render:
                  type: object
                  description: The render this one iterates on, null outside refine mode.
                  properties:
                    data: { type: [object, "null"] }
    AgentWallet:
      type: object
      properties:
        scope: { const: api }
        balance: { type: number }
        key:
          type: object
          properties:
            id: { type: string }
            label: { type: [string, "null"] }
            spend_cap: { type: number }
            spent: { type: number }
            remaining: { type: number }
            is_exhausted: { type: boolean }
        is_admissible:
          type: boolean
          description: Mirrors the admission middleware exactly — room on the key AND an eligible account.
    Errors:
      type: object
      properties:
        errors:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              status: { type: string }
              code: { type: string }
              title: { type: string }
              detail: { type: string }
        message:
          type: string
          description: Laravel validation replies are not JSON:API-shaped; their text lives here.
