{
  "openapi": "3.1.0",
  "info": {
    "title": "Ask Tone API",
    "version": "1.0.0-beta",
    "summary": "Ask once — get answers from a board of models, side by side or synthesized into one.",
    "description": "The Ask Tone public API runs one prompt across a board of models and returns either every answer side by side or a single synthesized answer. The API is in private beta for Pro and Team accounts; keys are issued manually by the Ask Tone team. Calls spend the account's credits (subscription first, then top-ups; promotional credits are never spent). You are charged for completed work only — failed model portions are refunded automatically. Human-readable documentation: https://docs.asktone.ai",
    "contact": {
      "name": "Ask Tone support",
      "url": "https://asktone.ai/contact"
    },
    "termsOfService": "https://asktone.ai/legal/terms"
  },
  "servers": [
    {
      "url": "https://api.asktone.ai",
      "description": "Canonical API host (always the current version)."
    },
    {
      "url": "https://asktone.ai/api/v1",
      "description": "Equivalent path-versioned form on the apex host."
    }
  ],
  "security": [{ "bearerAuth": [] }],
  "paths": {
    "/synthesize": {
      "post": {
        "operationId": "synthesize",
        "summary": "Run one prompt across a board of models",
        "description": "Runs the prompt across the requested board (or the full enabled catalog when `models` is omitted) and returns per-model answers plus, for `mode: \"synthesis\"`, a synthesized answer. Stateless: no conversation is created and no content is retained in chat history. Set `stream: true` for a Server-Sent Events response (see `x-sse-events`). Worst-case duration is bounded at 120 seconds server-side; set client timeouts to at least 130 seconds.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/SynthesizeRequest" },
              "examples": {
                "synthesis": {
                  "summary": "Default synthesis over an explicit board",
                  "value": {
                    "prompt": "Should we migrate from REST to GraphQL?",
                    "models": [
                      "3f2c1a9e-6f0d-4a2f-b1c8-3f6a1f2f9d10",
                      "8a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
                    ],
                    "mode": "synthesis"
                  }
                },
                "sideBySide": {
                  "summary": "Side-by-side answers, streamed",
                  "value": {
                    "prompt": "Name the biggest risk in this launch plan.",
                    "mode": "side_by_side",
                    "stream": true
                  }
                },
                "minimal": {
                  "summary": "Minimal request (full enabled catalog, synthesis, no streaming)",
                  "value": { "prompt": "Explain CRDTs to a product manager." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The settled result. For `stream: true` the response is `text/event-stream` instead — SSE frames named `response_chunk`, `response_complete`, `synthesis_start`, `synthesis_chunk`, `error`, and a final `done` event whose payload matches the JSON response shape (see `x-sse-events`).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SynthesizeResponse" }
              },
              "text/event-stream": {
                "schema": {
                  "type": "string",
                  "description": "SSE frames: `event: <name>` + `data: <json>` per frame; the event name is repeated as `type` inside each JSON payload."
                }
              }
            }
          },
          "400": {
            "description": "Invalid request: malformed JSON, empty or oversized prompt, empty `models` array or more than 3 ids, non-UUID ids, an unsupported mode (including `council`), or no requested id resolving to an enabled model. Nothing is charged.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "401": {
            "description": "Missing, malformed, unknown, or revoked bearer token (deliberately indistinguishable).",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "402": {
            "description": "The account's credits (subscription plus top-up) cannot cover the estimated cost. `error.options` lists the recovery paths in order: topup, upgrade, lite. Nothing is charged.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "403": {
            "description": "The key is valid but the owning account cannot use the API right now (for example a payment failure, cancellation, or refund state).",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "409": {
            "description": "Internal replay-safety collision (`idempotency_conflict`). Not expected in practice; retry once.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "Over the 30-requests-per-minute sliding window for this key. `error.retryAfter` (seconds) may be present. Nothing is charged.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "500": {
            "description": "Provider or internal failure that could not be reduced to a partial result (`provider_error` or `internal`). Single-model failures do not produce a 500 — they appear as status \"error\" entries in a 200 response, refunded.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        },
        "x-sse-events": {
          "response_chunk": {
            "payload": { "type": "response_chunk", "modelId": "uuid", "delta": "string" },
            "description": "A text fragment from one board model; accumulate per modelId."
          },
          "response_complete": {
            "payload": { "type": "response_complete", "modelId": "uuid" },
            "description": "That model's answer is finished."
          },
          "synthesis_start": {
            "payload": { "type": "synthesis_start" },
            "description": "All board answers are in; the synthesizer is starting (synthesis mode only)."
          },
          "synthesis_chunk": {
            "payload": { "type": "synthesis_chunk", "delta": "string" },
            "description": "A fragment of the synthesized answer."
          },
          "error": {
            "payload": { "type": "error", "code": "provider_error | internal", "message": "string" },
            "description": "The synthesis step failed or an internal error occurred; the stream still ends with done. Failing board models emit no error event — they surface as status \"error\" in done.responses."
          },
          "done": {
            "payload": {
              "type": "done",
              "id": "string",
              "synthesis": "string | null",
              "responses": "SynthesizeResponse.responses",
              "creditsCharged": "integer"
            },
            "description": "Always the final event — the settlement record, identical in shape to the non-streaming JSON response."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key issued by the Ask Tone team (private beta), of the form `tone_live_…`. Presented as `Authorization: Bearer tone_live_…`. Only a SHA-256 hash is stored server-side."
      }
    },
    "schemas": {
      "SynthesizeRequest": {
        "type": "object",
        "required": ["prompt"],
        "properties": {
          "prompt": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100000,
            "description": "The question to ask."
          },
          "models": {
            "type": "array",
            "items": { "type": "string", "format": "uuid" },
            "minItems": 1,
            "maxItems": 3,
            "description": "Model ids to use as the board. Omit the field entirely to run every enabled model. Ids not matching an enabled model are dropped; if none resolve, the request is rejected with 400."
          },
          "mode": {
            "type": "string",
            "enum": ["synthesis", "side_by_side"],
            "default": "synthesis",
            "description": "synthesis reconciles the answers into one; side_by_side returns only the per-model answers (synthesis comes back null)."
          },
          "stream": {
            "type": "boolean",
            "default": false,
            "description": "When true, respond with a Server-Sent Events stream instead of a single JSON body."
          }
        },
        "description": "Unknown fields are ignored."
      },
      "SynthesizeResponse": {
        "type": "object",
        "required": ["id", "synthesis", "responses", "creditsCharged"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Unique id for this call, of the form apiv1:<uuid>."
          },
          "synthesis": {
            "type": ["string", "null"],
            "description": "The reconciled answer. Null for side_by_side, when every model failed, or when the synthesizer failed (per-model answers are still returned and charged)."
          },
          "responses": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/ModelResponse" },
            "description": "One entry per board model. A model recovered via its single-hop fallback appears under the fallback model's slug."
          },
          "creditsCharged": {
            "type": "integer",
            "minimum": 0,
            "description": "The settled cost in credits, after per-model refunds. Authoritative."
          }
        }
      },
      "ModelResponse": {
        "type": "object",
        "required": ["model", "text", "status"],
        "properties": {
          "model": { "type": "string", "description": "The answering model's slug." },
          "text": {
            "type": "string",
            "description": "The model's answer. May be empty or partial when status is error."
          },
          "status": { "type": "string", "enum": ["complete", "error"] }
        }
      },
      "ApiError": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "invalid",
                  "unauthorized",
                  "insufficient_credits",
                  "forbidden",
                  "idempotency_conflict",
                  "rate_limited",
                  "provider_error",
                  "internal"
                ],
                "description": "Stable machine-readable identifier — dispatch on this, not on message text."
              },
              "message": {
                "type": "string",
                "description": "Short human-readable description. Not a contract; may be reworded."
              },
              "options": {
                "type": "array",
                "items": { "type": "string", "enum": ["topup", "upgrade", "lite"] },
                "description": "Present only on insufficient_credits — the recovery paths, in order."
              },
              "retryAfter": {
                "type": "integer",
                "minimum": 0,
                "description": "Present only on rate_limited (when known) — seconds until the window resets."
              }
            }
          }
        }
      }
    }
  }
}
