{
  "openapi": "3.1.0",
  "info": {
    "title": "Podsmith",
    "version": "0.1.0",
    "summary": "Agent-first podcast generation: sources in, two-host episode plus transcript, citations and manifest out.",
    "description": "Async job model: POST /api/podcasts returns a job id immediately, then poll GET /api/jobs/{id} until status is succeeded, failed or awaiting_approval. Polling also drives the pipeline, so a job that is never polled makes no progress. An MCP server exposing the same capability lives at /mcp.",
    "contact": {
      "url": "https://podsmith.pages.dev/docs"
    }
  },
  "servers": [
    {
      "url": "https://podsmith.pages.dev"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "generation",
      "description": "Submit and drive generation jobs."
    },
    {
      "name": "episodes",
      "description": "Retrieve finished episodes and their artifacts."
    },
    {
      "name": "discovery",
      "description": "Presets, voices and cost estimates. No authentication required."
    }
  ],
  "paths": {
    "/api/podcasts": {
      "post": {
        "tags": [
          "generation"
        ],
        "operationId": "generatePodcast",
        "summary": "Submit a generation job",
        "description": "Only sources is required; every other field has a documented default. Returns 202 with a job object. Send an Idempotency-Key header to make retries safe.",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Reusing a key returns the original job instead of starting a second one."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GenerateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Idempotent replay of an existing job."
          },
          "202": {
            "description": "Job accepted and started.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "503": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "get": {
        "tags": [
          "generation"
        ],
        "operationId": "listJobs",
        "summary": "List recent jobs",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 20,
              "maximum": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Recent jobs."
          }
        }
      }
    },
    "/api/jobs/{id}": {
      "get": {
        "tags": [
          "generation"
        ],
        "operationId": "getJobStatus",
        "summary": "Get job status and advance the pipeline",
        "description": "Returns the job. If the job is unfinished this call also runs the next pipeline stages before responding, so poll every few seconds. A failed job still returns 200 with the error inside the body.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Job state.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/jobs/{id}/approve": {
      "post": {
        "tags": [
          "generation"
        ],
        "operationId": "approveDraft",
        "summary": "Approve a draft and commit audio spend",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Job released."
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/jobs/{id}/reject": {
      "post": {
        "tags": [
          "generation"
        ],
        "operationId": "rejectDraft",
        "summary": "Reject a draft without rendering audio",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job rejected."
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/episodes/{id}": {
      "get": {
        "tags": [
          "episodes"
        ],
        "operationId": "getEpisode",
        "summary": "Episode summary and artifact links",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Episode."
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/episodes/{id}/audio.wav": {
      "get": {
        "tags": [
          "episodes"
        ],
        "operationId": "getEpisodeAudio",
        "summary": "Episode audio",
        "description": "24 kHz mono 16-bit PCM WAV. Supports HTTP range requests, so it can be given directly to a player.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "Range",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Full audio.",
            "content": {
              "audio/wav": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "206": {
            "description": "Partial content."
          },
          "416": {
            "description": "Range not satisfiable."
          }
        }
      }
    },
    "/api/episodes/{id}/transcript.json": {
      "get": {
        "tags": [
          "episodes"
        ],
        "operationId": "getTranscript",
        "summary": "Timestamped transcript with per-cue citations",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Transcript.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Transcript"
                }
              }
            }
          }
        }
      }
    },
    "/api/episodes/{id}/transcript.vtt": {
      "get": {
        "tags": [
          "episodes"
        ],
        "operationId": "getTranscriptVtt",
        "summary": "WebVTT transcript",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "WebVTT.",
            "content": {
              "text/vtt": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/episodes/{id}/transcript.srt": {
      "get": {
        "tags": [
          "episodes"
        ],
        "operationId": "getTranscriptSrt",
        "summary": "SubRip transcript",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "SubRip."
          }
        }
      }
    },
    "/api/episodes/{id}/manifest.json": {
      "get": {
        "tags": [
          "episodes"
        ],
        "operationId": "getManifest",
        "summary": "Audit manifest",
        "description": "The immutable record for the episode: sources, chunk-level citations, model versions, seed, groundedness score with every unsupported claim listed, ASR fidelity, loudness measurements and cost.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Manifest.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Manifest"
                }
              }
            }
          }
        }
      }
    },
    "/api/episodes/{id}/show-notes.json": {
      "get": {
        "tags": [
          "episodes"
        ],
        "operationId": "getShowNotes",
        "summary": "Show notes",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Show notes."
          }
        }
      }
    },
    "/api/episodes/{id}/chapters.json": {
      "get": {
        "tags": [
          "episodes"
        ],
        "operationId": "getChapters",
        "summary": "Chapter marks",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Chapters."
          }
        }
      }
    },
    "/api/estimate": {
      "post": {
        "tags": [
          "discovery"
        ],
        "operationId": "estimateCost",
        "summary": "Estimate cost and validate parameters",
        "description": "Takes the same body as POST /api/podcasts and validates it identically without generating anything, so a 200 here means generation will accept the request. No authentication required.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GenerateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Estimate."
          },
          "400": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/formats": {
      "get": {
        "tags": [
          "discovery"
        ],
        "operationId": "listFormats",
        "summary": "List format presets",
        "security": [],
        "responses": {
          "200": {
            "description": "Formats."
          }
        }
      }
    },
    "/api/voices": {
      "get": {
        "tags": [
          "discovery"
        ],
        "operationId": "listVoices",
        "summary": "List voices and curated host pairs",
        "security": [],
        "responses": {
          "200": {
            "description": "Voice catalogue."
          }
        }
      }
    },
    "/mcp": {
      "get": {
        "tags": [
          "discovery"
        ],
        "operationId": "mcpDescriptor",
        "summary": "MCP server descriptor",
        "security": [],
        "responses": {
          "200": {
            "description": "Server info, tool list and example payloads."
          }
        }
      },
      "post": {
        "tags": [
          "generation"
        ],
        "operationId": "mcpRpc",
        "summary": "MCP JSON-RPC endpoint",
        "description": "Streamable HTTP MCP transport. Methods: initialize, tools/list, tools/call, ping.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC response."
          },
          "202": {
            "description": "Notification accepted."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A key from the deployment PODSMITH_API_KEYS secret. Generation endpoints require it; discovery endpoints do not. A deployment with no keys configured refuses to generate at all."
      }
    },
    "responses": {
      "Error": {
        "description": "A typed error with a remediation hint.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message",
              "remediation",
              "status"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable code.",
                "examples": [
                  "source_too_large",
                  "groundedness_below_threshold",
                  "auth_not_configured"
                ]
              },
              "message": {
                "type": "string"
              },
              "remediation": {
                "type": "string",
                "description": "What to change to make the request succeed."
              },
              "status": {
                "type": "integer"
              }
            }
          }
        }
      },
      "Source": {
        "type": "object",
        "required": [
          "type"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "url",
              "text"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Required when type is url. HTML, text, Markdown, JSON or XML; not PDF."
          },
          "text": {
            "type": "string",
            "description": "Required when type is text. Markdown is accepted."
          },
          "title": {
            "type": "string",
            "description": "Optional label used in the citation manifest."
          }
        }
      },
      "Host": {
        "type": "object",
        "required": [
          "name",
          "voice"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 32,
            "description": "Short first name; used as the TTS speaker label."
          },
          "voice": {
            "type": "string",
            "enum": [
              "Zephyr",
              "Puck",
              "Charon",
              "Kore",
              "Fenrir",
              "Leda",
              "Orus",
              "Aoede",
              "Callirrhoe",
              "Autonoe",
              "Enceladus",
              "Iapetus",
              "Umbriel",
              "Algieba",
              "Despina",
              "Erinome",
              "Algenib",
              "Rasalgethi",
              "Laomedeia",
              "Achernar",
              "Alnilam",
              "Schedar",
              "Gacrux",
              "Pulcherrima",
              "Achird",
              "Zubenelgenubi",
              "Vindemiatrix",
              "Sadachbia",
              "Sadaltager",
              "Sulafat"
            ]
          },
          "persona": {
            "type": "string"
          }
        }
      },
      "GenerateRequest": {
        "type": "object",
        "required": [
          "sources"
        ],
        "properties": {
          "sources": {
            "type": "array",
            "minItems": 1,
            "maxItems": 12,
            "items": {
              "$ref": "#/components/schemas/Source"
            }
          },
          "format": {
            "type": "string",
            "enum": [
              "deep_dive",
              "brief",
              "debate",
              "critique",
              "interview",
              "explainer"
            ],
            "default": "deep_dive"
          },
          "target_minutes": {
            "type": "number",
            "description": "Defaults to the format default. Result lands within about ±15%."
          },
          "voice_pair": {
            "type": "string",
            "enum": [
              "uk_warm",
              "uk_brisk",
              "uk_considered",
              "us_bright",
              "us_measured"
            ],
            "default": "uk_warm"
          },
          "hosts": {
            "type": "array",
            "minItems": 2,
            "maxItems": 2,
            "items": {
              "$ref": "#/components/schemas/Host"
            },
            "description": "Exactly two hosts. The first is the explainer, the second the questioner."
          },
          "listener_level": {
            "type": "string",
            "enum": [
              "newcomer",
              "informed",
              "expert"
            ],
            "default": "informed"
          },
          "disfluency": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "Backchannels, interruptions and self-corrections. 0 clean, 0.5 natural, 1 loose. Defaults to the format default."
          },
          "style_direction": {
            "type": "string"
          },
          "require_approval": {
            "type": "boolean",
            "default": false
          },
          "groundedness_threshold": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "default": 0.75
          },
          "verify_audio": {
            "type": "boolean",
            "default": true
          },
          "disclosure": {
            "type": "boolean",
            "default": true
          },
          "language": {
            "type": "string",
            "default": "en-GB"
          },
          "seed": {
            "type": "integer"
          },
          "idempotency_key": {
            "type": "string"
          }
        }
      },
      "Job": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "object": {
            "type": "string",
            "const": "job"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "awaiting_approval",
              "succeeded",
              "failed"
            ]
          },
          "stage": {
            "type": "string",
            "enum": [
              "ingest",
              "plan",
              "script",
              "ground",
              "tts",
              "post",
              "verify",
              "finalize",
              "done"
            ]
          },
          "progress": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "episode_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "cost_estimate": {
            "$ref": "#/components/schemas/Cost"
          },
          "cost_actual": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Cost"
              },
              {
                "type": "null"
              }
            ]
          },
          "error": {
            "anyOf": [
              {
                "type": "object"
              },
              {
                "type": "null"
              }
            ]
          },
          "log": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "outline": {
            "type": "object"
          },
          "groundedness": {
            "type": "object"
          },
          "episode": {
            "type": "object",
            "description": "Artifact links, present once the job has succeeded."
          },
          "next_action": {
            "type": "object",
            "description": "What the caller should do next."
          }
        }
      },
      "Cost": {
        "type": "object",
        "properties": {
          "currency": {
            "type": "string",
            "const": "GBP"
          },
          "script_gbp": {
            "type": "number"
          },
          "tts_gbp": {
            "type": "number"
          },
          "qa_gbp": {
            "type": "number"
          },
          "total_gbp": {
            "type": "number"
          },
          "rates_version": {
            "type": "string"
          },
          "note": {
            "type": "string"
          }
        }
      },
      "Transcript": {
        "type": "object",
        "properties": {
          "duration_ms": {
            "type": "integer"
          },
          "timing_source": {
            "type": "string",
            "enum": [
              "segment_boundaries",
              "asr_refined"
            ]
          },
          "cues": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "index": {
                  "type": "integer"
                },
                "speaker": {
                  "type": "string"
                },
                "text": {
                  "type": "string"
                },
                "start_ms": {
                  "type": "integer"
                },
                "end_ms": {
                  "type": "integer"
                },
                "segment_id": {
                  "type": "string"
                },
                "cites": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  },
                  "description": "Source chunk ids backing this line."
                },
                "speculative": {
                  "type": "boolean",
                  "description": "True when the line acknowledges going beyond the sources."
                }
              }
            }
          }
        }
      },
      "Manifest": {
        "type": "object",
        "description": "The immutable per-episode audit record.",
        "properties": {
          "manifest_version": {
            "type": "integer",
            "const": 1
          },
          "episode_id": {
            "type": "string"
          },
          "generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "disclosure": {
            "type": "object"
          },
          "request": {
            "type": "object"
          },
          "models": {
            "type": "object"
          },
          "sources": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "citations": {
            "type": "array",
            "description": "One entry per cited source chunk, with the transcript cues that rest on it and an excerpt.",
            "items": {
              "type": "object"
            }
          },
          "quality": {
            "type": "object"
          },
          "audio": {
            "type": "object"
          },
          "script": {
            "type": "object"
          },
          "chapters": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "cost": {
            "$ref": "#/components/schemas/Cost"
          },
          "limitations": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      }
    }
  }
}