{
  "openapi": "3.1.0",
  "info": {
    "title": "Annsa API",
    "version": "2026-08-22",
    "summary": "Send customer feedback and call transcripts into Annsa, and reach its priorities and specs over MCP.",
    "description": "Annsa is the autonomous product intelligence system for growth. It turns customer feedback into revenue-weighted priorities and codebase-aware specs, delivered into Cursor, Claude Code and Codex over MCP, then tells customers when their feedback ships.\n\nThis document describes only what is public and callable today. Endpoints used by the Annsa web app on a user's own session are deliberately absent.\n\n## Which credential to use\n\nThere are two separate authentication systems, and they are not interchangeable:\n\n- **`sessionBearer`** — a Supabase session token belonging to a signed-in user. Required by `POST /feedback` and `POST /transcripts/upload`. A survey key sent here returns 401.\n- **`surveyKey`** — the key from Settings → Integrations → Survey. It authenticates `POST /surface/submit` **only**, and travels in the JSON body as `api_key`, not in a header — so it is described on the `WidgetSubmission` schema rather than as a security scheme, because OpenAPI has no way to express a body-field credential. This key is embedded in public web pages by design, so its reach is deliberately narrow.\n\nFor reading priorities, specs and search, use the MCP server rather than REST. It is OAuth 2.0 with PKCE and scoped `read`/`write`, and it is the surface built for agents. See `POST /mcp`.\n\n## Errors\n\nEvery error response is `application/json` in the shape `{\"detail\": \"...\"}` (the `Error` schema). `detail` is a human-readable string; it is not a stable machine code, so branch on the HTTP status rather than on the text.\n\n## Rate limits\n\nLimits are per authenticated principal and are stated per operation. A 429 carries `Retry-After` in seconds. There are no `RateLimit-*` headers on 2xx responses today, so an agent cannot read remaining quota before it is exhausted; back off on 429.\n\n## Versioning\n\nThis API is unversioned in the path. The `info.version` above is the date this document was published, and this document is the contract. Additive changes — new optional fields, new endpoints — ship without notice, so clients must ignore unknown response fields. Breaking changes are announced at https://annsa.ai/changelog. A formal deprecation window with `Deprecation` and `Sunset` response headers is not yet in force; do not infer one from this document.",
    "contact": {
      "name": "Annsa",
      "url": "https://annsa.ai/contact",
      "email": "support@annsa.ai"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://annsa.ai/terms"
    },
    "termsOfService": "https://annsa.ai/terms"
  },
  "servers": [
    {
      "url": "https://api.annsa.ai",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "description": "API guide, with worked examples",
    "url": "https://annsa.ai/docs/api"
  },
  "tags": [
    {
      "name": "Ingest",
      "description": "Getting customer signal into Annsa."
    },
    {
      "name": "Agents",
      "description": "The MCP surface — ranked priorities, codebase-aware specs, search."
    },
    {
      "name": "Service",
      "description": "Liveness."
    }
  ],
  "security": [
    {
      "sessionBearer": []
    }
  ],
  "paths": {
    "/feedback": {
      "post": {
        "operationId": "submitFeedback",
        "summary": "Submit one piece of feedback, or a batch",
        "description": "Stores customer feedback and queues it for classification, clustering and ranking.\n\nSend either a single item (a `text` field at the top level) or a batch (an `items` array). The two shapes return different bodies — see the responses below.\n\nAnnsa hashes the text, so the same content submitted twice creates one item. Supply `external_id` and the second submission is skipped on that id regardless of what the text says.\n\nRequires a signed-in user's session token. The survey key from Settings → Integrations → Survey does **not** authenticate this endpoint; use `POST /surface/submit` for that.\n\nRate limit: 5000 requests per hour.",
        "tags": ["Ingest"],
        "security": [
          {
            "sessionBearer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/FeedbackItem"
                  },
                  {
                    "$ref": "#/components/schemas/FeedbackBatch"
                  }
                ]
              },
              "examples": {
                "single": {
                  "summary": "One piece of feedback",
                  "value": {
                    "text": "The export is too slow on large datasets",
                    "customer_name": "Jane Smith",
                    "customer_email": "jane@example.com",
                    "revenue_band": 199,
                    "source": "api"
                  }
                },
                "batch": {
                  "summary": "Up to 1000 items in one request",
                  "value": {
                    "items": [
                      {
                        "text": "Can't find the export button",
                        "customer_email": "alex@example.com",
                        "revenue_band": 49
                      },
                      {
                        "text": "Love the new dashboard layout",
                        "customer_name": "Sam Lee"
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored. A single submission returns the created id, or a duplicate marker if the text was already present. A batch returns one summary object covering every item.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/FeedbackAccepted"
                    },
                    {
                      "$ref": "#/components/schemas/FeedbackDuplicate"
                    },
                    {
                      "$ref": "#/components/schemas/FeedbackBatchResult"
                    }
                  ]
                },
                "examples": {
                  "accepted": {
                    "value": {
                      "id": "7c2f1a94-0d6e-4a1b-9f33-2b6f0c5a71de"
                    }
                  },
                  "duplicate": {
                    "value": {
                      "id": "7c2f1a94-0d6e-4a1b-9f33-2b6f0c5a71de",
                      "duplicate": true,
                      "original_source": "slack"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/transcripts/upload": {
      "post": {
        "operationId": "uploadTranscript",
        "summary": "Upload a call transcript for segmentation",
        "description": "Accepts transcript text and returns immediately; segmentation into individual signals runs in the background.\n\n`title` is required, which the prose guide does not currently say. `text` must be between 50 and 500,000 characters.\n\nIf the transcript carries speaker labels, name the customer's label in `customer_speaker` so only their words become signals. Detect the available labels first with `POST /transcripts/detect-speakers`.\n\nRequires a signed-in user's session token. Rate limit: 500 requests per hour.",
        "tags": ["Ingest"],
        "security": [
          {
            "sessionBearer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TranscriptUpload"
              },
              "examples": {
                "discovery": {
                  "summary": "A discovery call",
                  "value": {
                    "title": "Acme — discovery call",
                    "text": "Interviewer: What made you look for something like this?\nJane: We were tracking feature requests in a spreadsheet and it kept going stale...",
                    "transcript_type": "discovery",
                    "transcript_date": "2026-08-14",
                    "company_name": "Acme",
                    "customer_name": "Jane Smith",
                    "customer_speaker": "Jane"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted for processing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TranscriptAccepted"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/surface/submit": {
      "post": {
        "operationId": "submitSurveyFeedback",
        "summary": "Submit feedback with a survey key",
        "description": "The endpoint the in-product survey widget posts to, and the only one a survey key authenticates.\n\nThe key travels in the request body as `api_key`, not in an `Authorization` header. That is deliberate: the key is embedded in public web pages, so it is scoped to this single write and nothing else. It cannot read anything, and it cannot reach `POST /feedback`.\n\nRate limit: 5000 requests per hour.",
        "tags": ["Ingest"],
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WidgetSubmission"
              },
              "examples": {
                "widget": {
                  "value": {
                    "api_key": "your-survey-key",
                    "text": "The export is too slow on large datasets",
                    "email": "jane@example.com",
                    "feedback_type": "bug",
                    "page_url": "https://example.com/reports",
                    "user_metadata": {
                      "plan": "pro",
                      "mrr": 199
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FeedbackAccepted"
                }
              }
            }
          },
          "401": {
            "description": "The `api_key` was missing, unknown or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "detail": "Invalid API key"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/mcp": {
      "post": {
        "operationId": "callMcp",
        "summary": "Call an Annsa MCP tool (JSON-RPC 2.0)",
        "description": "The agent surface. A Streamable HTTP MCP endpoint exposing six tools:\n\n- `annsa.priorities` — what should I work on? Ranked priorities with trend and confidence\n- `annsa.spec` — the full engineering spec for a priority, grounded in the connected repository\n- `annsa.ask` — ask anything about the feedback data: semantic search plus synthesis\n- `annsa.act` — start building, mark shipped, share back with customers, assign\n- `annsa.surveys` — how the in-product surveys are performing\n- `annsa.help` — how Annsa itself works\n\nThis is the only Annsa surface with scoped authorization. Obtain a token with OAuth 2.0 authorization code + PKCE (S256); clients may register themselves at the `registration_endpoint`. `read` covers priorities, specs and search; `write` covers state changes and Share Back.\n\nAn unauthenticated request returns 401 with a `WWW-Authenticate: Bearer` header naming the protected-resource metadata document, so a client can discover how to authenticate from one request.\n\nNote the different host: this endpoint is served from `https://app.annsa.ai`, not `api.annsa.ai`.",
        "tags": ["Agents"],
        "servers": [
          {
            "url": "https://app.annsa.ai",
            "description": "MCP resource server"
          }
        ],
        "security": [
          {
            "oauth2": ["read"]
          },
          {
            "oauth2": ["read", "write"]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JsonRpcRequest"
              },
              "examples": {
                "listTools": {
                  "summary": "List the available tools",
                  "value": {
                    "jsonrpc": "2.0",
                    "id": 1,
                    "method": "tools/list"
                  }
                },
                "callTool": {
                  "summary": "Ask what to build next",
                  "value": {
                    "jsonrpc": "2.0",
                    "id": 2,
                    "method": "tools/call",
                    "params": {
                      "name": "annsa.priorities",
                      "arguments": {}
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A JSON-RPC 2.0 response, or an SSE stream when the client accepts `text/event-stream`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JsonRpcResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid bearer token. Carries `WWW-Authenticate` naming the protected-resource metadata.",
            "headers": {
              "WWW-Authenticate": {
                "description": "e.g. `Bearer realm=\"Annsa MCP\", resource_metadata=\"https://app.annsa.ai/.well-known/oauth-protected-resource/mcp\"`",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/health": {
      "get": {
        "operationId": "getHealth",
        "summary": "Liveness and build information",
        "description": "Unauthenticated. Returns the running build so a client can tell which version answered.",
        "tags": ["Service"],
        "security": [],
        "responses": {
          "200": {
            "description": "The service is up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthStatus"
                },
                "example": {
                  "status": "healthy",
                  "name": "Circuit API",
                  "version": "0.1.0",
                  "build_sha": "unknown",
                  "started_at": "2026-08-21T07:36:16.192017Z",
                  "environment": "production"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "sessionBearer": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "A Supabase session token for a signed-in user. Grants that user's own workspace scope and nothing wider. This is not the survey key — sending a survey key here returns 401."
      },
      "oauth2": {
        "type": "oauth2",
        "description": "OAuth 2.0 for the MCP resource `https://app.annsa.ai/mcp`. PKCE with S256 is required. Clients may register themselves dynamically (RFC 7591); server metadata is published per RFC 8414 at `/.well-known/oauth-authorization-server` and protected-resource metadata per RFC 9728 at `/.well-known/oauth-protected-resource/mcp`.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://app.annsa.ai/mcp/auth",
            "tokenUrl": "https://app.annsa.ai/mcp/token",
            "refreshUrl": "https://app.annsa.ai/mcp/token",
            "scopes": {
              "read": "Read priorities, specs, survey results and search.",
              "write": "Change state — start building, mark shipped, share back with customers."
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request body was malformed or a required field was absent.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "detail": "Either 'text' or 'items' field required"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing, invalid or expired credential.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "detail": "Authentication token is invalid or expired. Please log in again."
            }
          }
        }
      },
      "Forbidden": {
        "description": "The credential is valid but lacks the scope for this call.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unprocessable": {
        "description": "The body parsed but failed validation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "detail": "Invalid feedback format. Please check your data and try again."
            }
          }
        }
      },
      "RateLimited": {
        "description": "Rate limit exceeded.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServerError": {
        "description": "Unexpected server error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "title": "Error",
        "description": "The single error shape every endpoint returns. `detail` is human-readable and may change; branch on the HTTP status code instead.",
        "required": ["detail"],
        "properties": {
          "detail": {
            "type": "string",
            "description": "What went wrong, in plain language.",
            "examples": ["Invalid API key"]
          }
        }
      },
      "FeedbackItem": {
        "type": "object",
        "title": "FeedbackItem",
        "description": "One piece of customer feedback.",
        "required": ["text"],
        "properties": {
          "text": {
            "type": "string",
            "minLength": 1,
            "description": "The feedback content. The only required field."
          },
          "customer_name": {
            "type": "string",
            "description": "Appears in Customer Voice quotes."
          },
          "customer_email": {
            "type": "string",
            "format": "email",
            "description": "Enables close-the-loop notifications when the request ships."
          },
          "revenue_band": {
            "type": ["number", "string"],
            "description": "What this customer pays you, e.g. 199. An amount is kept as an amount and never collapsed into a band. Legacy band strings (enterprise / paid / free) are still accepted.",
            "examples": [199]
          },
          "sku": {
            "type": "string",
            "description": "Product tier or plan identifier."
          },
          "external_id": {
            "type": "string",
            "description": "Your own identifier. A second submission carrying the same external_id is skipped, whatever the text says."
          },
          "source": {
            "type": "string",
            "description": "Where this came from. Defaults to `api`. Setting it is what makes Activity Log filtering useful.",
            "default": "api",
            "examples": ["api", "widget", "csv", "slack"]
          },
          "project_id": {
            "type": "string",
            "description": "Home this feedback to a specific project."
          }
        },
        "additionalProperties": true
      },
      "FeedbackBatch": {
        "type": "object",
        "title": "FeedbackBatch",
        "description": "Up to 1000 items in one request.",
        "required": ["items"],
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 1000,
            "items": {
              "$ref": "#/components/schemas/FeedbackItem"
            }
          },
          "project_id": {
            "type": "string",
            "description": "Applies to every item in the batch."
          }
        }
      },
      "FeedbackAccepted": {
        "type": "object",
        "title": "FeedbackAccepted",
        "description": "A single item was stored.",
        "required": ["id"],
        "properties": {
          "id": {
            "type": "string",
            "description": "The stored feedback's identifier."
          }
        }
      },
      "FeedbackDuplicate": {
        "type": "object",
        "title": "FeedbackDuplicate",
        "description": "The text or external_id already existed, so nothing new was stored. The id points at the item already held.",
        "required": ["id", "duplicate"],
        "properties": {
          "id": {
            "type": "string"
          },
          "duplicate": {
            "type": "boolean",
            "const": true
          },
          "original_source": {
            "type": "string",
            "description": "Where the item already held came from.",
            "examples": ["slack"]
          }
        }
      },
      "FeedbackBatchResult": {
        "type": "object",
        "title": "FeedbackBatchResult",
        "description": "The outcome of a batch submission, item by item.",
        "required": ["batch", "total_items", "successful", "failed"],
        "properties": {
          "batch": {
            "type": "boolean",
            "const": true
          },
          "total_items": {
            "type": "integer"
          },
          "successful": {
            "type": "integer"
          },
          "queued": {
            "type": "integer",
            "description": "Held against a plan limit rather than dropped, and released when capacity returns."
          },
          "duplicates": {
            "type": "integer"
          },
          "failed": {
            "type": "integer"
          },
          "results": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "duplicates_list": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "errors": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "performance": {
            "type": "object",
            "properties": {
              "total_time_seconds": {
                "type": "number"
              },
              "items_per_second": {
                "type": "number"
              }
            }
          }
        }
      },
      "TranscriptUpload": {
        "type": "object",
        "title": "TranscriptUpload",
        "required": ["title", "text"],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500
          },
          "text": {
            "type": "string",
            "minLength": 50,
            "maxLength": 500000,
            "description": "The transcript body. Speaker labels are preserved if present."
          },
          "transcript_type": {
            "type": "string",
            "default": "discovery",
            "description": "What kind of conversation this was."
          },
          "transcript_date": {
            "type": "string",
            "description": "When the conversation happened. Preserved on export."
          },
          "company_name": {
            "type": "string"
          },
          "customer_name": {
            "type": "string"
          },
          "customer_email": {
            "type": "string",
            "format": "email"
          },
          "revenue_band": {
            "type": "string"
          },
          "sku": {
            "type": "string"
          },
          "project_id": {
            "type": "string"
          },
          "customer_speaker": {
            "type": "string",
            "maxLength": 64,
            "description": "The customer's speaker label. Supply it and only their words become signals, so the interviewer's questions never reach the roadmap. Omit it and segmentation treats the whole transcript as one voice."
          }
        }
      },
      "TranscriptAccepted": {
        "type": "object",
        "title": "TranscriptAccepted",
        "description": "Accepted; segmentation continues in the background.",
        "properties": {
          "id": {
            "type": "string"
          },
          "status": {
            "type": "string"
          }
        },
        "additionalProperties": true
      },
      "WidgetSubmission": {
        "type": "object",
        "title": "WidgetSubmission",
        "description": "A submission from the in-product survey. Note that the credential is the `api_key` field below, not a header.",
        "required": ["api_key"],
        "properties": {
          "api_key": {
            "type": "string",
            "description": "The survey key from Settings → Integrations → Survey. This is the credential for this endpoint."
          },
          "text": {
            "type": "string",
            "description": "The feedback content."
          },
          "email": {
            "type": "string",
            "maxLength": 255
          },
          "feedback_type": {
            "type": "string",
            "enum": ["bug", "feature", "question", "other"]
          },
          "page_url": {
            "type": "string",
            "maxLength": 2000,
            "description": "Where the feedback was given."
          },
          "screenshot": {
            "type": "string",
            "description": "Base64-encoded image."
          },
          "surface": {
            "type": "string",
            "enum": ["float", "inline", "bar", "thumbs", "page", "widget"],
            "description": "Which collection surface captured this."
          },
          "rating": {
            "type": "integer"
          },
          "user_metadata": {
            "type": "object",
            "description": "Who the submitter is. Recognised keys include `email`, `name`, `full_name`, `displayName`, `id`, `plan`, `tier`, and any of `amount` / `mrr` / `arr` / `revenue` for what they pay.",
            "additionalProperties": true
          }
        }
      },
      "HealthStatus": {
        "type": "object",
        "title": "HealthStatus",
        "required": ["status"],
        "properties": {
          "status": {
            "type": "string",
            "examples": ["healthy"]
          },
          "name": {
            "type": "string"
          },
          "version": {
            "type": "string"
          },
          "build_sha": {
            "type": "string",
            "description": "The deployed commit, where the build recorded one."
          },
          "started_at": {
            "type": "string",
            "format": "date-time"
          },
          "environment": {
            "type": "string",
            "examples": ["production"]
          }
        }
      },
      "JsonRpcRequest": {
        "type": "object",
        "title": "JsonRpcRequest",
        "required": ["jsonrpc", "method"],
        "properties": {
          "jsonrpc": {
            "type": "string",
            "const": "2.0"
          },
          "id": {
            "type": ["string", "integer"]
          },
          "method": {
            "type": "string",
            "examples": ["initialize", "tools/list", "tools/call"]
          },
          "params": {
            "type": "object",
            "additionalProperties": true
          }
        }
      },
      "JsonRpcResponse": {
        "type": "object",
        "title": "JsonRpcResponse",
        "required": ["jsonrpc"],
        "properties": {
          "jsonrpc": {
            "type": "string",
            "const": "2.0"
          },
          "id": {
            "type": ["string", "integer"]
          },
          "result": {
            "type": "object",
            "additionalProperties": true
          },
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "integer"
              },
              "message": {
                "type": "string"
              }
            }
          }
        }
      }
    }
  }
}
