{
  "openapi": "3.1.0",
  "info": {
    "title": "Ledgerline Help API",
    "version": "1.0.0",
    "summary": "The routes behind the Ledgerline help assistant.",
    "description": "Four routes: ask a question and stream a cited answer, hand a conversation to a person, rate an answer, and read the evaluation history. Ledgerline is a fictional company and the docs are fictional. The API serves the help site and its embeddable widget on the same origin, so it sends no CORS headers; call it from a server. Every response carries an X-Request-Id header, and every failure has the same JSON body."
  },
  "servers": [
    {
      "url": "https://help.phoenixcodes.org",
      "description": "Production"
    },
    {
      "url": "http://localhost:3000",
      "description": "Local development"
    }
  ],
  "tags": [
    {
      "name": "Chat",
      "description": "Ask a question and read the cited answer as it streams."
    },
    {
      "name": "Support",
      "description": "Hand over to a person, and rate an answer."
    },
    {
      "name": "Evaluation",
      "description": "The recorded results of `pnpm eval`."
    }
  ],
  "paths": {
    "/api/chat": {
      "post": {
        "operationId": "createChatCompletion",
        "summary": "Ask a question",
        "description": "Searches the docs, and answers from the passages found, citing them as [1], [2] and so on.\n\nOrder of work: rate limit, retrieval, the confidence gate (below the threshold no model is called and the answer is the standard decline), the daily token budget, then the model.\n\nThe response is a stream in the Vercel AI SDK UI message format (server-sent events). It carries the answer as text deltas and two data parts: `data-meta` (a ChatMeta, sent first with the retrieval confidence and again at the end with the outcome) and `data-citations` (the numbered passages the model was shown, a Citation per passage).\n\nA visitor cookie (`ll_vid`, HttpOnly) is set on the first call so a conversation can continue. Ask follow-ups by sending the returned conversationId back.",
        "tags": [
          "Chat"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ChatRequest"
              },
              "example": {
                "message": "How do I verify a webhook signature?",
                "surface": "site"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The answer, streamed.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              },
              "X-RateLimit-Limit": {
                "description": "Questions allowed per window for this client.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "description": "Questions left in the current window.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string",
                  "description": "Server-sent events of AI SDK UI message chunks. Data chunks: `data-meta` (ChatMeta) and `data-citations` (an array of Citation)."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      }
    },
    "/api/tickets": {
      "post": {
        "operationId": "createTicket",
        "summary": "Talk to a human",
        "description": "Opens a support ticket for the admin queue. No email is sent anywhere: the ticket is a row a person reads in /admin. At most 5 per hour per client.",
        "tags": [
          "Support"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TicketRequest"
              },
              "example": {
                "name": "Ada",
                "email": "ada@example.com",
                "summary": "I asked about crypto payouts and the docs have no answer. Can a person help?",
                "conversationId": "6f1c1f2e-8a0e-4c58-9a57-3a2f4a8b7c10"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The ticket was created.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ticket"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      }
    },
    "/api/feedback": {
      "post": {
        "operationId": "createFeedback",
        "summary": "Rate an answer",
        "description": "A thumbs up (1) or down (-1) on an assistant answer, with an optional comment. One rating per answer: a second one replaces the first. `messageId` is the one in the ChatMeta of that answer. At most 30 per 10 minutes per client.",
        "tags": [
          "Support"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FeedbackRequest"
              },
              "example": {
                "messageId": "0b9d2c1a-5e44-4e0f-b6d2-13f1d6a6f5ee",
                "rating": -1,
                "comment": "The answer skipped the retry limit."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The rating was saved.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      }
    },
    "/api/eval/runs": {
      "get": {
        "operationId": "listEvalRuns",
        "summary": "Evaluation runs",
        "description": "The runs of `pnpm eval`, newest first: retrieval, declining, answer quality, speed, tokens and cost. These are the numbers on the \"How well it works\" page, /eval. Responses are cached for a minute at the edge. At most 60 per 10 minutes per client.",
        "tags": [
          "Evaluation"
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "How many runs to return, newest first.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 30,
              "default": 10
            }
          },
          {
            "name": "results",
            "in": "query",
            "description": "Include the per-question results (each question, its answer and the judge's note). Large: leave it off for a history chart.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The runs. Empty before the first run has been recorded.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "runs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/EvalRun"
                      }
                    }
                  },
                  "required": [
                    "runs"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      }
    }
  },
  "components": {
    "headers": {
      "RequestId": {
        "description": "Identifies this request in the server logs. Send your own X-Request-Id (6 to 64 letters, digits, dots, dashes or underscores) to correlate, or read the one returned.",
        "schema": {
          "type": "string",
          "example": "req_k3j9x0a1b2c4"
        }
      },
      "RetryAfter": {
        "description": "Seconds until the next request can succeed.",
        "schema": {
          "type": "integer"
        }
      }
    },
    "responses": {
      "InvalidRequest": {
        "description": "The request is not valid. `message` says which field and why.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "There is no answer with that id.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests from this client. Wait `retryAfterSeconds`.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          },
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unavailable": {
        "description": "A dependency (search, the model, or the database) is unavailable, or on /api/chat the daily token budget is used up (`budget_exhausted`, with `resetsAt`). `escalate` is true when a person should take over.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          },
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "ChatRequest": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "description": "The question. Whitespace at either end is trimmed before the length is checked."
          },
          "conversationId": {
            "type": "string",
            "format": "uuid",
            "description": "Continue a conversation. Omit to start one; the id comes back in ChatMeta."
          },
          "surface": {
            "type": "string",
            "enum": [
              "site",
              "widget"
            ],
            "default": "site",
            "description": "Where the question was asked. The widget's visitor cookie is partitioned for use in an iframe."
          }
        },
        "required": [
          "message"
        ]
      },
      "ChatMeta": {
        "type": "object",
        "description": "The `data-meta` part of the chat stream.",
        "properties": {
          "conversationId": {
            "type": "string",
            "format": "uuid"
          },
          "messageId": {
            "type": "string",
            "format": "uuid",
            "description": "Id of the assistant message; what feedback refers to."
          },
          "confidence": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "Retrieval confidence."
          },
          "threshold": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "gateDeclined": {
            "type": "boolean",
            "description": "The confidence gate stopped the request before any model call."
          },
          "declined": {
            "type": "boolean",
            "description": "The answer is the standard \"not in the docs\" decline (by the gate or the model)."
          },
          "escalate": {
            "type": "boolean",
            "description": "Offer to hand over to a person."
          },
          "related": {
            "type": "array",
            "description": "Closest pages, offered with a decline when the match was not hopeless.",
            "items": {
              "type": "object",
              "properties": {
                "docSlug": {
                  "type": "string"
                },
                "breadcrumb": {
                  "type": "string"
                }
              },
              "required": [
                "docSlug",
                "breadcrumb"
              ]
            }
          },
          "provider": {
            "type": "string",
            "enum": [
              "anthropic",
              "workers-ai"
            ]
          },
          "model": {
            "type": "string"
          },
          "promptTokens": {
            "type": "integer"
          },
          "completionTokens": {
            "type": "integer"
          },
          "costUsd": {
            "type": "number"
          },
          "ttftMs": {
            "type": "integer",
            "description": "Milliseconds from request to the first token."
          },
          "latencyMs": {
            "type": "integer"
          }
        },
        "required": [
          "conversationId",
          "messageId",
          "confidence",
          "threshold",
          "gateDeclined",
          "declined",
          "escalate"
        ]
      },
      "Citation": {
        "type": "object",
        "description": "One passage the model was shown. The answer cites a subset by its number, [n].",
        "properties": {
          "n": {
            "type": "integer",
            "minimum": 1
          },
          "chunkId": {
            "type": "string",
            "format": "uuid"
          },
          "docSlug": {
            "type": "string",
            "description": "Open it at /docs/{docSlug}?chunk={chunkId}."
          },
          "breadcrumb": {
            "type": "string"
          },
          "excerpt": {
            "type": "string"
          }
        },
        "required": [
          "n",
          "chunkId",
          "docSlug",
          "breadcrumb",
          "excerpt"
        ]
      },
      "TicketRequest": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 200
          },
          "summary": {
            "type": "string",
            "minLength": 10,
            "maxLength": 2000
          },
          "conversationId": {
            "type": "string",
            "format": "uuid",
            "description": "The conversation the ticket is about."
          }
        },
        "required": [
          "name",
          "email",
          "summary"
        ]
      },
      "Ticket": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "ref": {
            "type": "string",
            "example": "LL-3F9A1C2B",
            "description": "Short reference to quote to a person."
          },
          "status": {
            "const": "open"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "ref",
          "status",
          "createdAt"
        ]
      },
      "FeedbackRequest": {
        "type": "object",
        "properties": {
          "messageId": {
            "type": "string",
            "format": "uuid"
          },
          "rating": {
            "enum": [
              1,
              -1
            ],
            "description": "1 is a thumbs up, -1 a thumbs down."
          },
          "comment": {
            "type": "string",
            "maxLength": 1000
          }
        },
        "required": [
          "messageId",
          "rating"
        ]
      },
      "Error": {
        "type": "object",
        "description": "Every failure that is not a stream has this body.",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "invalid_request",
                  "rate_limited",
                  "budget_exhausted",
                  "upstream_unavailable",
                  "unauthorized",
                  "not_found",
                  "internal_error"
                ]
              },
              "message": {
                "type": "string"
              },
              "requestId": {
                "type": "string"
              },
              "retryAfterSeconds": {
                "type": "integer"
              },
              "resetsAt": {
                "type": "string",
                "format": "date-time",
                "description": "budget_exhausted only: when the daily budget resets (UTC)."
              }
            },
            "required": [
              "code",
              "message",
              "requestId"
            ]
          },
          "escalate": {
            "type": "boolean",
            "description": "Offer to hand over to a person."
          }
        },
        "required": [
          "error"
        ]
      },
      "EvalRun": {
        "type": "object",
        "description": "One run of `pnpm eval`.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "gitSha": {
            "type": [
              "string",
              "null"
            ],
            "description": "Short commit the run was made from; a trailing -dirty means uncommitted changes."
          },
          "model": {
            "type": [
              "string",
              "null"
            ],
            "description": "Model that wrote the answers (provider/model id)."
          },
          "judgeModel": {
            "type": [
              "string",
              "null"
            ],
            "description": "Model that scored faithfulness and correctness."
          },
          "questionCount": {
            "type": "integer",
            "description": "All questions in the run."
          },
          "outOfScopeCount": {
            "type": "integer",
            "description": "How many of them the docs do not answer."
          },
          "confidenceThreshold": {
            "type": [
              "number",
              "null"
            ],
            "description": "CONFIDENCE_THRESHOLD in force for the run."
          },
          "answerPromptVersion": {
            "type": [
              "string",
              "null"
            ]
          },
          "judgePromptVersion": {
            "type": [
              "string",
              "null"
            ]
          },
          "retrieval": {
            "type": "object",
            "description": "Whether the right document was retrieved, over the questions the docs answer.",
            "properties": {
              "hitAt1": {
                "type": "number",
                "minimum": 0,
                "maximum": 1
              },
              "hitAt3": {
                "type": "number",
                "minimum": 0,
                "maximum": 1
              },
              "hitAt8": {
                "type": "number",
                "minimum": 0,
                "maximum": 1
              },
              "mrr": {
                "type": "number",
                "minimum": 0,
                "maximum": 1,
                "description": "Mean reciprocal rank."
              }
            },
            "required": [
              "hitAt1",
              "hitAt3",
              "hitAt8",
              "mrr"
            ]
          },
          "declining": {
            "type": "object",
            "properties": {
              "declineAccuracy": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 0,
                "maximum": 1,
                "description": "Out-of-scope questions that ended in a decline, by the gate or the model."
              },
              "gateDeclineAccuracy": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 0,
                "maximum": 1,
                "description": "Out-of-scope questions the confidence gate stopped before any model call."
              },
              "falseDeclineRate": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 0,
                "maximum": 1,
                "description": "Questions the docs answer that were declined anyway."
              }
            },
            "required": [
              "declineAccuracy",
              "gateDeclineAccuracy",
              "falseDeclineRate"
            ]
          },
          "quality": {
            "type": "object",
            "properties": {
              "faithfulness": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 1,
                "maximum": 5,
                "description": "Mean judge score: is every claim in the answer supported by the passages."
              },
              "correctness": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 1,
                "maximum": 5,
                "description": "Mean judge score against the reference answer."
              },
              "citationRate": {
                "type": [
                  "number",
                  "null"
                ],
                "minimum": 0,
                "maximum": 1,
                "description": "Answers whose [n] markers all map to a retrieved passage."
              }
            },
            "required": [
              "faithfulness",
              "correctness",
              "citationRate"
            ]
          },
          "speed": {
            "type": "object",
            "description": "Milliseconds, measured by the caller of the route.",
            "properties": {
              "latencyP50Ms": {
                "type": "integer"
              },
              "latencyP95Ms": {
                "type": "integer"
              },
              "ttftP50Ms": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Time to the first streamed token."
              },
              "ttftP95Ms": {
                "type": [
                  "integer",
                  "null"
                ]
              }
            },
            "required": [
              "latencyP50Ms",
              "latencyP95Ms",
              "ttftP50Ms",
              "ttftP95Ms"
            ]
          },
          "tokens": {
            "type": "object",
            "properties": {
              "total": {
                "type": "integer"
              },
              "prompt": {
                "type": "integer"
              },
              "completion": {
                "type": "integer"
              }
            },
            "required": [
              "total",
              "prompt",
              "completion"
            ]
          },
          "costUsd": {
            "type": "number",
            "description": "Billed cost of the run in US dollars."
          },
          "results": {
            "type": "array",
            "description": "Per-question detail. Present only when `results=true`.",
            "items": {
              "$ref": "#/components/schemas/EvalQuestionResult"
            }
          }
        },
        "required": [
          "id",
          "createdAt",
          "gitSha",
          "model",
          "judgeModel",
          "questionCount",
          "outOfScopeCount",
          "confidenceThreshold",
          "answerPromptVersion",
          "judgePromptVersion",
          "retrieval",
          "declining",
          "quality",
          "speed",
          "tokens",
          "costUsd"
        ]
      },
      "EvalQuestionResult": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "kind": {
            "enum": [
              "gold",
              "out_of_scope"
            ],
            "description": "Gold questions have an answer in the docs."
          },
          "question": {
            "type": "string"
          },
          "expectedDocSlugs": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "retrievedDocSlugs": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "firstHitRank": {
            "type": [
              "integer",
              "null"
            ],
            "description": "1-based rank of the first expected document."
          },
          "confidence": {
            "type": "number"
          },
          "gateDeclined": {
            "type": "boolean"
          },
          "declined": {
            "type": "boolean"
          },
          "pass": {
            "type": "boolean"
          },
          "answer": {
            "type": "string"
          },
          "validCitations": {
            "type": "integer"
          },
          "totalCitations": {
            "type": "integer"
          },
          "faithfulness": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 5
          },
          "correctness": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 5
          },
          "judgeNote": {
            "type": [
              "string",
              "null"
            ]
          },
          "latencyMs": {
            "type": "integer"
          },
          "ttftMs": {
            "type": "integer"
          },
          "promptTokens": {
            "type": "integer"
          },
          "completionTokens": {
            "type": "integer"
          },
          "provider": {
            "type": "string"
          },
          "model": {
            "type": "string"
          },
          "costUsd": {
            "type": "number"
          },
          "error": {
            "type": "string"
          }
        },
        "required": [
          "id",
          "kind",
          "question",
          "expectedDocSlugs",
          "retrievedDocSlugs",
          "firstHitRank",
          "confidence",
          "gateDeclined",
          "declined",
          "pass",
          "answer",
          "validCitations",
          "totalCitations",
          "faithfulness",
          "correctness",
          "judgeNote",
          "latencyMs",
          "promptTokens",
          "completionTokens"
        ]
      }
    }
  }
}