{
  "openapi": "3.1.0",
  "info": {
    "title": "Tsunode Portfolio API",
    "version": "1.0.0",
    "summary": "AI chat assistant that answers questions about Tsunode (Gabriel Tsunoda).",
    "description": "Public API of tsunode.com. The only endpoint is a chat completion stream scoped to questions about Tsunode's career, projects and stack.\n\n## Authentication\n\nNone. No API key, no sign-up. Requests are identified by IP address for rate limiting only.\n\n## Rate limits\n\n10 requests per minute per IP, in a fixed 60-second window. Every response carries the RFC 9331 `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy` headers; a 429 also carries `Retry-After`.\n\n## Versioning and deprecation\n\nThe API is versioned in the URL path: `/api/v1/chat` is canonical. Breaking changes ship as a new version (`/api/v2/...`) and never as an edit to a released one. Additive changes (new optional fields, new event types) may ship inside a version. If a version is retired it will answer with the `Deprecation` (RFC 9745) and `Sunset` (RFC 8594) headers for at least six months before it stops responding, and https://tsunode.com/api will document the timeline. `/api/chat` is a permanent unversioned alias of v1 and is not deprecated.\n\n## Prefer markdown for content\n\nIf you want the site's content rather than an answer to a question, send `Accept: text/markdown` to any page URL and you get a markdown representation of the same resource, with no model call. See https://tsunode.com/llms.txt.",
    "contact": {
      "name": "Tsunode",
      "email": "hello@tsunode.com",
      "url": "https://tsunode.com/contact"
    },
    "license": {
      "name": "Free to use, no warranty",
      "identifier": "MIT"
    }
  },
  "externalDocs": {
    "description": "Human-readable API documentation",
    "url": "https://tsunode.com/api"
  },
  "servers": [{ "url": "https://tsunode.com" }],
  "paths": {
    "/api/v1/chat": {
      "post": {
        "operationId": "chat",
        "summary": "Ask the portfolio assistant about Tsunode",
        "description": "Streams an assistant reply as a UI message stream (Vercel AI SDK format). The assistant only answers questions about Tsunode and replies in the language of the last user message.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ChatRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Streamed assistant reply.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string",
                  "description": "Server-sent events. Each line is `data: <json>`; concatenate the `delta` of every `text-delta` event to rebuild the answer.",
                  "examples": [
                    "data: {\"type\":\"text-delta\",\"delta\":\"Tsunode works at \"}"
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      },
      "options": {
        "operationId": "chatOptions",
        "summary": "Discover the verbs this endpoint accepts",
        "responses": {
          "204": {
            "description": "No content. The `Allow` header lists the accepted methods.",
            "headers": {
              "Allow": {
                "description": "Accepted HTTP methods.",
                "schema": { "type": "string", "examples": ["POST, OPTIONS"] }
              }
            }
          }
        }
      }
    },
    "/api/chat": {
      "post": {
        "operationId": "chatAlias",
        "summary": "Unversioned alias of POST /api/v1/chat",
        "description": "Identical behaviour, request schema and responses as `/api/v1/chat`. Kept permanently for the site's own chat UI and for integrations that predate the versioned path. Not deprecated.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ChatRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Streamed assistant reply.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "text/event-stream": {
                "schema": { "type": "string" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    }
  },
  "components": {
    "headers": {
      "RateLimitLimit": {
        "description": "Requests allowed in the current window.",
        "schema": { "type": "integer", "examples": [10] }
      },
      "RateLimitRemaining": {
        "description": "Requests left in the current window.",
        "schema": { "type": "integer", "examples": [7] }
      },
      "RateLimitReset": {
        "description": "Seconds until a slot frees up.",
        "schema": { "type": "integer", "examples": [42] }
      },
      "RateLimitPolicy": {
        "description": "Quota policy in RFC 9331 form.",
        "schema": { "type": "string", "examples": ["10;w=60"] }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying.",
        "schema": { "type": "integer", "examples": [42] }
      },
      "Allow": {
        "description": "Accepted HTTP methods.",
        "schema": { "type": "string", "examples": ["POST, OPTIONS"] }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Malformed body, missing `messages`, or content the endpoint refuses.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "Wrong HTTP verb.",
        "headers": { "Allow": { "$ref": "#/components/headers/Allow" } },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limit exceeded (10 requests per minute per IP).",
        "headers": {
          "Retry-After": { "$ref": "#/components/headers/RetryAfter" },
          "RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" }
          }
        }
      },
      "InternalError": {
        "description": "Unexpected error while processing the message.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" }
          }
        }
      }
    },
    "schemas": {
      "ChatRequest": {
        "type": "object",
        "required": ["messages"],
        "properties": {
          "messages": {
            "type": "array",
            "minItems": 1,
            "description": "Conversation history in Vercel AI SDK UIMessage format.",
            "items": { "$ref": "#/components/schemas/UIMessage" }
          },
          "locale": {
            "type": "string",
            "description": "Fallback locale used only when the message language is ambiguous.",
            "enum": ["en", "pt-BR"],
            "default": "en"
          }
        }
      },
      "UIMessage": {
        "type": "object",
        "required": ["role", "parts"],
        "properties": {
          "id": { "type": "string" },
          "role": { "type": "string", "enum": ["user", "assistant", "system"] },
          "parts": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["type"],
              "properties": {
                "type": { "type": "string", "examples": ["text"] },
                "text": { "type": "string" }
              }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error", "code", "message", "hint"],
        "description": "Every failure uses this shape. Branch on `code`; it is stable across versions.",
        "properties": {
          "error": {
            "type": "string",
            "description": "Human message in Portuguese, shown by the site's own chat UI."
          },
          "code": {
            "type": "string",
            "description": "Stable machine-readable error code.",
            "enum": [
              "invalid_request",
              "blocked_content",
              "rate_limited",
              "method_not_allowed",
              "internal_error"
            ]
          },
          "message": {
            "type": "string",
            "description": "Machine-readable description in English."
          },
          "hint": {
            "type": "string",
            "description": "What the caller should do next to recover."
          },
          "documentation": {
            "type": "string",
            "format": "uri",
            "description": "Human-readable documentation for this endpoint."
          }
        }
      }
    }
  }
}
