{
  "openapi": "3.1.0",
  "info": {
    "title": "Throughline API — one-round-trip surface",
    "version": "1.0.0",
    "summary": "Bundles, turnovers, media, and purchase orders — the seven /api/v1 endpoints built for tools that want one call per question.",
    "description": "Throughline extends the experts who run a production — it never replaces them. This document covers ONLY the round-trip surface described at https://docs.leeme.co/api.html#one-round-trip-clamped-to-the-caller: scene bundles, character bundles, turnovers (list + submit), media (metadata + content), and a vendor's own purchase orders. It is a deliberately scoped slice of the full v1 API, not the whole thing — the resource-by-resource endpoints (GET /production, GET /scenes, POST /assets, and so on) are documented in prose at https://docs.leeme.co/api.html but are not spec'd here. `POST /mcp` is included as an extension: the same surface over MCP/JSON-RPC rather than plain REST.",
    "contact": {
      "name": "Throughline docs",
      "url": "https://docs.leeme.co"
    }
  },
  "externalDocs": {
    "description": "Full API reference (prose)",
    "url": "https://docs.leeme.co/api.html"
  },
  "servers": [
    {
      "url": "https://throughline-backend-idkf.onrender.com",
      "description": "Pilot — provisional host. Will move to a leeme.co domain."
    }
  ],
  "tags": [
    { "name": "Bundles", "description": "One GET that assembles everything about one scene or character, server-side." },
    { "name": "Turnovers", "description": "A vendor engagement's own deliveries, and submitting work back on one." },
    { "name": "Media", "description": "A piece of media, read by id rather than by guessing a path." },
    { "name": "Purchase orders", "description": "A vendor's own purchase orders and its own cost-report line." },
    { "name": "Extension", "description": "Not part of the seven REST endpoints above — the same surface over a different transport." }
  ],
  "security": [ { "BearerAuth": [] } ],
  "paths": {
    "/api/v1/bundles/scene/{node_id}": {
      "get": {
        "operationId": "getSceneBundle",
        "tags": ["Bundles"],
        "summary": "Get a scene bundle",
        "description": "Everything about one scene in a single call: its script state, its linked assets and their released versions, its shoot days, its approvals, its department notes. Requires the `read` scope. A production-wide key gets the unclamped read GET /v1/assets/{id} already gives; an engagement-bound key gets exactly its own engagement's slice, and a scene it has no grant on 404s exactly like a wrong id would.",
        "parameters": [
          { "$ref": "#/components/parameters/NodeId" },
          { "$ref": "#/components/parameters/IfNoneMatch" }
        ],
        "responses": {
          "200": {
            "description": "Scene bundle.",
            "headers": { "ETag": { "$ref": "#/components/headers/ETag" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SceneBundle" } } }
          },
          "304": { "$ref": "#/components/responses/NotModified" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/bundles/character/{node_id}": {
      "get": {
        "operationId": "getCharacterBundle",
        "tags": ["Bundles"],
        "summary": "Get a character bundle",
        "description": "A character's costumes plus the ingredient files that fed them — the relationship a pipeline can't reconstruct itself from a resource list. Requires `read`; reach-clamped the same way the scene bundle is.",
        "parameters": [
          { "$ref": "#/components/parameters/NodeId" },
          { "$ref": "#/components/parameters/IfNoneMatch" }
        ],
        "responses": {
          "200": {
            "description": "Character bundle.",
            "headers": { "ETag": { "$ref": "#/components/headers/ETag" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CharacterBundle" } } }
          },
          "304": { "$ref": "#/components/responses/NotModified" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/turnovers": {
      "get": {
        "operationId": "listTurnovers",
        "tags": ["Turnovers"],
        "summary": "List this key's engagement turnovers",
        "description": "The deliveries this key's engagement holds a grant against — a `read` grant or a `submit` grant, either counts. Requires `read`. Empty (never an error) for a plain production-wide key, or for an engagement with no grant at all — this route has no meaning without an engagement to hold one.",
        "responses": {
          "200": {
            "description": "This engagement's turnovers. An empty array is a valid, non-error answer.",
            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TurnoverRow" } } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/api/v1/turnovers/{id}/submissions": {
      "post": {
        "operationId": "submitTurnoverWork",
        "tags": ["Turnovers"],
        "summary": "Submit work on a turnover",
        "description": "Work coming back on a delivery. Requires the `submit:turnover` scope on the key AND a live `submit` grant on THIS delivery for the key's engagement — either alone is refused. Every uploaded file becomes its own media object (see GET /v1/media/{id}) rather than a raw path handed back; nothing in the request names where a file lands on disk.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The delivery id.",
            "schema": { "type": "integer" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": { "type": "string", "description": "Defaults to the delivery's own title if omitted." },
                  "note": { "type": "string", "description": "Free text attached to the submission." },
                  "files": {
                    "type": "array",
                    "items": { "type": "string", "format": "binary" },
                    "description": "One or more files. At least one is required."
                  }
                },
                "required": ["files"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The submission that was recorded.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TurnoverSubmission" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Missing the `submit:turnover` scope, no engagement to submit as, or no live `submit` grant on this specific delivery.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": {
            "description": "At least one file is required.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/v1/media/{id}": {
      "get": {
        "operationId": "getMedia",
        "tags": ["Media"],
        "summary": "Get media metadata",
        "description": "A piece of media's metadata, read by id rather than by guessing a path. Requires `read`. Visible unconditionally to a production-wide key; for an engagement-bound key, work submitted on a turnover is visible only to the engagement that uploaded it — never through the delivery it was submitted against, even to another engagement holding a `read` grant on that same delivery.",
        "parameters": [ { "$ref": "#/components/parameters/MediaId" } ],
        "responses": {
          "200": {
            "description": "Media metadata.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MediaDetail" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/media/{id}/content": {
      "get": {
        "operationId": "getMediaContent",
        "tags": ["Media"],
        "summary": "Stream media bytes",
        "description": "The bytes themselves, streamed. Same visibility rule and the same `read` scope as GET /v1/media/{id} — this is the one call that actually resolves the file.",
        "parameters": [ { "$ref": "#/components/parameters/MediaId" } ],
        "responses": {
          "200": {
            "description": "The file's raw bytes. Content-Type depends on the underlying file.",
            "content": { "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/v1/purchase-orders": {
      "get": {
        "operationId": "getPurchaseOrders",
        "tags": ["Purchase orders"],
        "summary": "Get this vendor's purchase orders and cost-report line",
        "description": "A vendor's own purchase orders and its own cost-report line — nothing else money-shaped, never a production's whole topsheet and never another vendor's rows. Requires the `read:money` scope AND a live `read:own_po` grant on the engagement. A key without the scope is refused with a 403 like any other missing scope; a key that holds the scope but has no engagement, or an engagement with no money grant, answers with an empty result rather than an error.",
        "responses": {
          "200": {
            "description": "This vendor's purchase orders and cost-report line. Empty arrays and a null week_of are valid, non-error answers for an engagement with no money grant.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PurchaseOrdersResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/mcp": {
      "post": {
        "operationId": "mcpCall",
        "tags": ["Extension"],
        "x-throughline-extension": true,
        "summary": "MCP (Model Context Protocol) endpoint",
        "description": "An MCP server onto this same v1 surface, over Streamable HTTP: one JSON-RPC 2.0 message per request body, no batching, no SSE fallback. The MCP layer resolves the same bearer key and calls the identical function the matching REST endpoint calls, so a key gets the same clamped answer and the same refusal on either door. This entry is a pointer, not a full spec of the fifteen tools, six resources, and four prompts behind it — see https://docs.leeme.co/mcp.html for the tool catalogue and https://docs.leeme.co/api.html for what each tool's REST equivalent returns.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/JsonRpcRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A JSON-RPC result or error object. Auth, scope, engagement-door, and tenancy refusals arrive as REST's own bytes unwrapped (see the 401/403 responses below) rather than a JSON-RPC error envelope; a bad method or tool/resource/prompt name gets a JSON-RPC error object instead, because there's no REST call for those to match.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JsonRpcResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "tl_...",
        "description": "A Throughline access key: `Authorization: Bearer tl_...`. Minted by a production's admin from Settings — production-wide (reads/writes the whole production) or engagement-bound (clamped to one vendor organization's own engagement). One key, one production; there is no cross-production key. See https://docs.leeme.co/keys.html."
      }
    },
    "parameters": {
      "NodeId": {
        "name": "node_id",
        "in": "path",
        "required": true,
        "description": "The graph node id of the scene or character.",
        "schema": { "type": "integer" }
      },
      "MediaId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The media object's id.",
        "schema": { "type": "integer" }
      },
      "IfNoneMatch": {
        "name": "If-None-Match",
        "in": "header",
        "required": false,
        "description": "An ETag from a previous response to this same bundle. A match returns a bare 304 instead of re-sending the whole document.",
        "schema": { "type": "string" }
      }
    },
    "headers": {
      "ETag": {
        "description": "A stable hash of the assembled document. Send it back as If-None-Match on the next request.",
        "schema": { "type": "string" }
      }
    },
    "responses": {
      "NotModified": {
        "description": "Not modified — the caller's If-None-Match matched the current ETag. No body.",
        "headers": { "ETag": { "$ref": "#/components/headers/ETag" } }
      },
      "Unauthorized": {
        "description": "Missing or malformed Authorization header, or an unknown/revoked key.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Forbidden": {
        "description": "The key lacks the scope this endpoint needs, or an engagement-bound key called an endpoint outside the bundle/turnover/media/purchase-order surface.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "No such object, OR the object exists but this key's engagement has no grant reaching it — the two cases are deliberately indistinguishable to the caller.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "The standard error body every refusal on this surface returns.",
        "properties": {
          "detail": { "type": "string" }
        },
        "required": ["detail"]
      },
      "Scene": {
        "type": "object",
        "properties": {
          "node_id": { "type": "integer" },
          "number": { "type": "string" },
          "slugline": { "type": "string" },
          "synopsis": { "type": "string" },
          "status": { "type": "string", "description": "Not a closed set on this object: besides the planned/active/blocked/done life cycle a script revision can also mark a scene omitted, so no enum is declared here (unlike the write-side status accepted by POST /assets, which never targets scenes)." },
          "revision_color": { "type": "string" },
          "elements": { "type": "object", "additionalProperties": true },
          "episode_id": { "type": "integer", "description": "Present only on an episodic production." },
          "episode_code": { "type": "string", "description": "Present only on an episodic production, e.g. \"S2E7\"." }
        },
        "required": ["node_id", "number", "slugline", "status", "revision_color", "elements"],
        "additionalProperties": true
      },
      "AssetVersionSummary": {
        "type": "object",
        "description": "One released version of an asset, as it rides inside a bundle's released_versions list.",
        "properties": {
          "id": { "type": "integer" },
          "version": { "type": "integer" },
          "variant": { "type": "string" },
          "status": { "type": "string" },
          "note": { "type": ["string", "null"] },
          "media_path": { "type": ["string", "null"] },
          "created_by": { "type": ["string", "null"] },
          "created_by_person_id": { "type": ["integer", "null"] },
          "archived": { "type": "boolean" },
          "created_at": { "type": ["string", "null"], "format": "date-time" },
          "released": { "type": "boolean", "description": "Always true inside a bundle's released_versions — unreleased versions never appear here." }
        },
        "required": ["id", "version", "variant", "status", "released"],
        "additionalProperties": true
      },
      "SceneAsset": {
        "type": "object",
        "properties": {
          "node_id": { "type": "integer" },
          "label": { "type": "string" },
          "kind": { "type": "string" },
          "status": { "type": "string" },
          "released_versions": { "type": "array", "items": { "$ref": "#/components/schemas/AssetVersionSummary" } }
        },
        "required": ["node_id", "label", "kind", "status", "released_versions"]
      },
      "ShootDayRow": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "day_number": { "type": "integer" },
          "date": { "type": "string" },
          "status": { "type": "string" },
          "general_call": { "type": ["string", "null"] }
        },
        "required": ["id", "day_number", "date", "status"]
      },
      "ApprovalRow": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "title": { "type": "string" },
          "status": { "type": "string" },
          "mode": { "type": "string" },
          "due": { "type": ["string", "null"] }
        },
        "required": ["id", "title", "status", "mode"]
      },
      "DepartmentNoteRow": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "summary": { "type": "string" },
          "created_at": { "type": ["string", "null"], "format": "date-time" }
        },
        "required": ["id", "summary"]
      },
      "SceneBundle": {
        "type": "object",
        "properties": {
          "version": { "type": "integer", "description": "The bundle document's own shape version." },
          "scene": { "$ref": "#/components/schemas/Scene" },
          "assets": { "type": "array", "items": { "$ref": "#/components/schemas/SceneAsset" } },
          "shoot_days": { "type": "array", "items": { "$ref": "#/components/schemas/ShootDayRow" } },
          "approvals": { "type": "array", "items": { "$ref": "#/components/schemas/ApprovalRow" } },
          "department_notes": { "type": "array", "items": { "$ref": "#/components/schemas/DepartmentNoteRow" } },
          "etag": { "type": "string" }
        },
        "required": ["version", "scene", "assets", "shoot_days", "approvals", "department_notes", "etag"]
      },
      "Ingredient": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "kind": { "type": "string" },
          "label": { "type": "string" },
          "relation": { "type": ["string", "null"] },
          "media_path": { "type": ["string", "null"] }
        },
        "required": ["id", "kind", "label"]
      },
      "Costume": {
        "type": "object",
        "properties": {
          "node_id": { "type": "integer" },
          "label": { "type": "string" },
          "status": { "type": "string" },
          "released_versions": { "type": "array", "items": { "$ref": "#/components/schemas/AssetVersionSummary" } },
          "ingredients": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/Ingredient" },
            "description": "De-duped union of every released version's own ingredients."
          }
        },
        "required": ["node_id", "label", "status", "released_versions", "ingredients"]
      },
      "CharacterBundle": {
        "type": "object",
        "properties": {
          "version": { "type": "integer" },
          "character": {
            "type": "object",
            "properties": {
              "node_id": { "type": "integer" },
              "label": { "type": "string" },
              "status": { "type": "string" }
            },
            "required": ["node_id", "label", "status"]
          },
          "costumes": { "type": "array", "items": { "$ref": "#/components/schemas/Costume" } },
          "etag": { "type": "string" }
        },
        "required": ["version", "character", "costumes", "etag"]
      },
      "TurnoverRow": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "title": { "type": "string" },
          "status": { "type": "string", "enum": ["draft", "blocked", "ready", "sent", "accepted"] },
          "due": { "type": "string" },
          "external_to": { "type": "string" },
          "spec": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
          "sent_at": { "type": ["string", "null"], "format": "date-time" },
          "can_submit": {
            "type": "boolean",
            "description": "Whether THIS engagement specifically holds a submit grant on this delivery. A read-only grant lists the delivery here but answers false."
          }
        },
        "required": ["id", "title", "status", "spec", "can_submit"]
      },
      "SubmissionMediaRow": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "label": { "type": "string" },
          "kind": { "type": "string" }
        },
        "required": ["id", "label", "kind"]
      },
      "TurnoverSubmission": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "delivery_id": { "type": "integer" },
          "status": { "type": "string" },
          "title": { "type": "string" },
          "note": { "type": "string" },
          "media": { "type": "array", "items": { "$ref": "#/components/schemas/SubmissionMediaRow" } },
          "created_at": { "type": ["string", "null"], "format": "date-time" }
        },
        "required": ["id", "delivery_id", "status", "title", "media"]
      },
      "MediaDetail": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "kind": { "type": "string" },
          "label": { "type": "string" },
          "node_id": { "type": ["integer", "null"] },
          "delivery_id": { "type": ["integer", "null"] },
          "work_item_id": { "type": ["integer", "null"], "description": "Set only for media submitted on a turnover." },
          "created_by": { "type": ["string", "null"] },
          "created_at": { "type": ["string", "null"], "format": "date-time" }
        },
        "required": ["id", "kind", "label"]
      },
      "PurchaseOrderRow": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "department_id": { "type": ["integer", "null"], "description": "Null for a production-wide commitment (an insurance rider, a completion-bond fee) that belongs to nobody's envelope — and so is never returned here." },
          "account": { "type": "string" },
          "vendor": { "type": "string" },
          "description": { "type": "string" },
          "amount": { "type": "number" },
          "status": { "type": "string", "enum": ["open", "partial", "closed"] },
          "date": { "type": "string" }
        },
        "required": ["id", "account", "vendor", "description", "amount", "status", "date"]
      },
      "CostReportLine": {
        "type": "object",
        "description": "One account line off the production's active weekly cost report, restricted to the accounts this engagement's granted department(s) cover.",
        "properties": {
          "account": { "type": "string" },
          "budget": { "type": "number" }
        },
        "required": ["account"],
        "additionalProperties": true
      },
      "PurchaseOrdersResponse": {
        "type": "object",
        "properties": {
          "purchase_orders": { "type": "array", "items": { "$ref": "#/components/schemas/PurchaseOrderRow" } },
          "line": { "type": "array", "items": { "$ref": "#/components/schemas/CostReportLine" } },
          "week_of": { "type": ["string", "null"] }
        },
        "required": ["purchase_orders", "line", "week_of"]
      },
      "JsonRpcRequest": {
        "type": "object",
        "description": "A single JSON-RPC 2.0 request object. Batched arrays are not supported (-32600).",
        "properties": {
          "jsonrpc": { "const": "2.0" },
          "id": { "anyOf": [{ "type": "string" }, { "type": "integer" }] },
          "method": {
            "type": "string",
            "description": "initialize | tools/list | resources/list | prompts/list | prompts/get | tools/call | resources/read"
          },
          "params": { "type": "object", "additionalProperties": true }
        },
        "required": ["jsonrpc", "method"]
      },
      "JsonRpcResponse": {
        "type": "object",
        "properties": {
          "jsonrpc": { "const": "2.0" },
          "id": { "anyOf": [{ "type": "string" }, { "type": "integer" }, { "type": "null" }] },
          "result": { "type": "object", "additionalProperties": true },
          "error": {
            "type": "object",
            "properties": {
              "code": { "type": "integer", "enum": [-32700, -32600, -32601, -32602] },
              "message": { "type": "string" }
            },
            "required": ["code", "message"]
          }
        },
        "required": ["jsonrpc", "id"]
      }
    }
  }
}
