{
  "openapi": "3.1.0",
  "info": {
    "title": "NORTH28 STUDIO API",
    "description": "Read-only API exposing NORTH28 STUDIO information, services, and site pages. All endpoints are public — no authentication required. An MCP server is also available at POST /api/mcp for agent-native access.\n\n## API Versioning\n\nThe current API version is **v1**. Version is communicated via the `X-API-Version` response header on every response. Clients may optionally pin a version by sending the `API-Version: 1` request header; unrecognised version values fall back to the latest stable version.\n\n## Deprecation Policy\n\nEndpoints are supported for a minimum of **12 months** after a deprecation notice is issued. Deprecated endpoints will return a `Deprecation` response header with the date the endpoint will be removed and a `Sunset` header with the planned sunset date per [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594). The `Link` header will point to the recommended replacement.",
    "version": "1.1.0",
    "contact": { "name": "NORTH28 STUDIO", "email": "hi@north28.studio", "url": "https://north28.studio" },
    "license": { "name": "Proprietary" },
    "x-logo": { "url": "https://north28.studio/images/Vector.svg" },
    "x-api-version": "1",
    "x-versioning": {
      "strategy": "header",
      "current": "1",
      "supported": ["1"],
      "header": "API-Version",
      "responseHeader": "X-API-Version",
      "deprecationPolicy": "12 months notice minimum; deprecated endpoints advertise Deprecation and Sunset headers per RFC 8594"
    },
    "x-rest-error-schema": "#/components/schemas/ApiError",
    "x-rest-pagination-schema": "#/components/schemas/PaginatedEnvelope",
    "x-rest-async-job-schema": "#/components/schemas/AsyncJobAccepted",
    "x-rest-patterns": {
      "pagination": {
        "style": "cursor",
        "request": {
          "query": ["limit", "cursor"]
        },
        "response": {
          "envelope": {
            "items": "data",
            "nextCursor": "page.nextCursor",
            "hasMore": "page.hasMore"
          }
        },
        "note": "Used by GET /api/pages and GET /api/services when limit or cursor is provided."
      },
      "asyncJob": {
        "submission": {
          "statusCode": 202,
          "headers": ["Location", "Retry-After"],
          "body": "AsyncJobAccepted"
        },
        "polling": {
          "resource": "/api/jobs/{jobId}",
          "statusField": "status"
        },
        "note": "POST /api/jobs returns 202; poll GET /api/jobs/{jobId} until status is completed or failed."
      },
      "async-job": {
        "submission": {
          "statusCode": 202,
          "headers": ["Location", "Retry-After"],
          "body": "AsyncJobAccepted"
        },
        "polling": {
          "resource": "/api/jobs/{jobId}",
          "statusField": "status"
        },
        "note": "Alias of asyncJob — POST /api/jobs, poll GET /api/jobs/{jobId}."
      }
    }
  },
  "servers": [
    { "url": "https://north28.studio", "description": "Production" },
    { "url": "https://north28.studio", "description": "Sandbox — read-only, stateless endpoints; safe to call freely without side effects", "x-environment": "sandbox" }
  ],
  "security": [],
  "paths": {
    "/api/studio": {
      "get": {
        "operationId": "getStudioInfo",
        "summary": "Get studio information",
        "description": "Returns NORTH28 STUDIO identity: name, description, website URL, contact email, and slogan.",
        "tags": ["Studio"],
        "parameters": [
          { "$ref": "#/components/parameters/ApiVersion" }
        ],
        "responses": {
          "200": {
            "description": "Studio information",
            "headers": { "$ref": "#/components/headers/RateLimitBundle" },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/StudioInfo" },
                "example": {
                  "name": "NORTH28 STUDIO",
                  "description": "North28 is a creative studio specialising in strategy, digital products, branding, and audiovisual content.",
                  "url": "https://north28.studio",
                  "contact": { "email": "hi@north28.studio" },
                  "slogan": "Bold brands. No fluff. No boring."
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/services": {
      "get": {
        "operationId": "listServices",
        "summary": "List all services",
        "description": "Returns the full catalogue of services offered by NORTH28 STUDIO. Omit `limit` and `cursor` for a legacy array response. Provide `limit` (and optionally `cursor`) for cursor-based pagination with `{ data, page: { nextCursor, hasMore } }`.",
        "tags": ["Studio"],
        "parameters": [
          { "$ref": "#/components/parameters/ApiVersion" },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Cursor" }
        ],
        "responses": {
          "200": {
            "description": "Service list (array when unpaginated, envelope when limit/cursor is used).",
            "headers": { "$ref": "#/components/headers/RateLimitBundle" },
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/Service" }
                    },
                    { "$ref": "#/components/schemas/PaginatedServiceList" }
                  ]
                },
                "examples": {
                  "legacyArray": {
                    "summary": "Unpaginated array",
                    "value": [
                      { "id": "strategy", "title": "Strategy", "description": "Figuring out what you actually need, before we build what you asked for." },
                      { "id": "digital", "title": "Digital", "description": "Websites and apps built from scratch. Scalable, fast, and idiot-proof." }
                    ]
                  },
                  "paginated": {
                    "summary": "Cursor page (limit=2)",
                    "value": {
                      "data": [
                        { "id": "strategy", "title": "Strategy", "description": "Figuring out what you actually need, before we build what you asked for." },
                        { "id": "digital", "title": "Digital", "description": "Websites and apps built from scratch. Scalable, fast, and idiot-proof." }
                      ],
                      "page": { "nextCursor": "eyJvZmZzZXQiOjJ9", "hasMore": true }
                    }
                  }
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/pages": {
      "get": {
        "operationId": "listPages",
        "summary": "List all public pages",
        "description": "Returns all public pages of the North28 website with their URLs. Omit `limit` and `cursor` for a legacy array response. Provide `limit` (and optionally `cursor`) for cursor-based pagination with `{ data, page: { nextCursor, hasMore } }`.",
        "tags": ["Studio"],
        "parameters": [
          { "$ref": "#/components/parameters/ApiVersion" },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Cursor" }
        ],
        "responses": {
          "200": {
            "description": "Page list (array when unpaginated, envelope when limit/cursor is used).",
            "headers": { "$ref": "#/components/headers/RateLimitBundle" },
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/Page" }
                    },
                    { "$ref": "#/components/schemas/PaginatedPageList" }
                  ]
                },
                "examples": {
                  "legacyArray": {
                    "summary": "Unpaginated array",
                    "value": [
                      { "name": "Home", "url": "https://north28.studio/" },
                      { "name": "Works", "url": "https://north28.studio/works" },
                      { "name": "Contact", "url": "https://north28.studio/contact" }
                    ]
                  },
                  "paginated": {
                    "summary": "Cursor page (limit=3)",
                    "value": {
                      "data": [
                        { "name": "Home", "url": "https://north28.studio/" },
                        { "name": "Works", "url": "https://north28.studio/works" },
                        { "name": "About Us", "url": "https://north28.studio/about-us" }
                      ],
                      "page": { "nextCursor": "eyJvZmZzZXQiOjN9", "hasMore": true }
                    }
                  }
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/jobs": {
      "post": {
        "operationId": "submitAsyncJob",
        "summary": "Submit async job",
        "description": "Accepts a long-running operation for asynchronous execution. Currently supports `operation: \"batch\"` with the same payload shape as `POST /api/batch`. Returns **202 Accepted** with `Location` and `Retry-After` headers pointing at the job resource to poll.",
        "tags": ["Jobs"],
        "parameters": [
          { "$ref": "#/components/parameters/ApiVersion" },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Client-generated unique key (UUID recommended) to safely retry submissions.",
            "schema": { "type": "string", "format": "uuid", "example": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AsyncJobSubmitRequest" },
              "example": {
                "operation": "batch",
                "payload": { "requests": ["studio", "services"] }
              }
            }
          }
        },
        "responses": {
          "202": { "$ref": "#/components/responses/AsyncJobAccepted" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/jobs/{jobId}": {
      "get": {
        "operationId": "getAsyncJob",
        "summary": "Poll async job status",
        "description": "Returns the current status of an async job submitted via `POST /api/jobs`. Poll until `status` is `completed` or `failed`.",
        "tags": ["Jobs"],
        "parameters": [
          { "$ref": "#/components/parameters/ApiVersion" },
          {
            "name": "jobId",
            "in": "path",
            "required": true,
            "description": "Opaque job identifier returned by `POST /api/jobs`.",
            "schema": { "type": "string", "example": "eyJvcGVyYXRpb24iOiJiYXRjaCJ9" }
          }
        ],
        "responses": {
          "200": {
            "description": "Job status (and result when completed).",
            "headers": { "$ref": "#/components/headers/RateLimitBundle" },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AsyncJobStatus" },
                "example": {
                  "jobId": "eyJvcGVyYXRpb24iOiJiYXRjaCJ9",
                  "status": "completed",
                  "result": {
                    "responses": [
                      { "endpoint": "studio", "status": 200, "data": { "name": "NORTH28 STUDIO" } }
                    ]
                  }
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/batch": {
      "post": {
        "operationId": "batchRequest",
        "summary": "Batch / bulk request",
        "description": "Execute multiple endpoint lookups in a single HTTP call. Accepts either a top-level array of endpoint names or an object with a `requests` array. Each item can be a string (`\"studio\"`) or an object (`{ \"endpoint\": \"studio\" }`). Maximum 10 requests per call. Allowed endpoints: `studio`, `services`, `pages`.",
        "tags": ["Studio"],
        "parameters": [
          { "$ref": "#/components/parameters/ApiVersion" },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Client-generated unique key (UUID recommended) to safely retry requests. The server echoes this key in the response header.",
            "schema": { "type": "string", "format": "uuid", "example": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/BatchRequest" },
              "examples": {
                "objectForm": {
                  "summary": "Object form — requests key",
                  "value": { "requests": ["studio", "services", "pages"] }
                },
                "arrayForm": {
                  "summary": "Array form — top-level array",
                  "value": ["studio", "services"]
                },
                "objectItems": {
                  "summary": "Object items",
                  "value": { "requests": [{ "endpoint": "studio" }, { "endpoint": "pages" }] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Array of individual responses, one per requested endpoint.",
            "headers": {
              "RateLimit-Limit":     { "$ref": "#/components/headers/RateLimit-Limit" },
              "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
              "RateLimit-Reset":     { "$ref": "#/components/headers/RateLimit-Reset" },
              "RateLimit-Policy":    { "$ref": "#/components/headers/RateLimit-Policy" },
              "Idempotency-Key": {
                "description": "Echoed from the request Idempotency-Key header, if provided.",
                "schema": { "type": "string" }
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BatchResponse" },
                "example": {
                  "responses": [
                    { "endpoint": "studio",   "status": 200, "data": { "name": "NORTH28 STUDIO" } },
                    { "endpoint": "services", "status": 200, "data": [{ "id": "strategy" }] }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/ask": {
      "post": {
        "operationId": "nlwebAsk",
        "summary": "NLWeb conversational search",
        "description": "Natural-language query endpoint following the [NLWeb](https://nlweb.ai) protocol. Returns ranked results from the NORTH28 STUDIO site. Set `streaming: true` (or send `Accept: text/event-stream`) to receive results as a Server-Sent Events stream; each SSE event carries a `{type:\"result\", ...}` object and the stream is terminated with a `{type:\"done\"}` event.",
        "tags": ["NLWeb"],
        "parameters": [
          { "$ref": "#/components/parameters/ApiVersion" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/NlwebAskRequest" },
              "examples": {
                "basicQuery": {
                  "summary": "Simple query",
                  "value": { "query": "what services does north28 offer?" }
                },
                "streamingQuery": {
                  "summary": "Streaming query",
                  "value": { "query": "how do I contact north28?", "streaming": true }
                },
                "siteScoped": {
                  "summary": "Site-scoped query",
                  "value": { "query": "portfolio", "site": "north28.studio" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Search results. Content-Type is `application/json` for standard requests and `text/event-stream` for streaming requests.",
            "headers": {
              "RateLimit-Limit":     { "$ref": "#/components/headers/RateLimit-Limit" },
              "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
              "RateLimit-Reset":     { "$ref": "#/components/headers/RateLimit-Reset" },
              "RateLimit-Policy":    { "$ref": "#/components/headers/RateLimit-Policy" },
              "X-API-Version":       { "$ref": "#/components/headers/X-API-Version" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/NlwebAskResponse" },
                "example": {
                  "query": "what services does north28 offer?",
                  "results": [
                    { "url": "https://north28.studio/", "name": "Strategy Service", "score": 0.8, "site": "north28.studio", "description": "Figuring out what you actually need, before we build what you asked for.", "answer": "NORTH28 STUDIO's Strategy service helps you figure out what you actually need before building it." }
                  ]
                }
              },
              "text/event-stream": {
                "schema": { "$ref": "#/components/schemas/NlwebStreamEvent" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      },
      "options": {
        "operationId": "nlwebAskOptions",
        "summary": "CORS preflight for /ask",
        "tags": ["NLWeb"],
        "responses": { "204": { "description": "No content" } }
      }
    },
    "/api/mcp": {
      "post": {
        "operationId": "mcpJsonRpc",
        "summary": "MCP JSON-RPC endpoint",
        "description": "Model Context Protocol (MCP) server using JSON-RPC 2.0. Supports initialize, tools/list, tools/call, resources/list, and resources/read methods.\n\nIf the request body is not valid JSON, the server responds with HTTP 400 and a JSON-RPC 2.0 error object (`code: -32700`, `message: Parse error`). All other HTTP error statuses on this route use the standard REST `ApiError` envelope documented in `components/schemas/ApiError`.",
        "tags": ["MCP"],
        "parameters": [
          { "$ref": "#/components/parameters/ApiVersion" },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Client-generated unique key (UUID recommended) to safely retry requests. The server echoes this key in the response header.",
            "schema": { "type": "string", "format": "uuid", "example": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/JsonRpcRequest" },
              "examples": {
                "initialize": {
                  "summary": "Initialize session",
                  "value": { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "my-agent", "version": "1.0" } } }
                },
                "toolsList": {
                  "summary": "List available tools",
                  "value": { "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
                },
                "toolCall": {
                  "summary": "Call get_studio_info",
                  "value": { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "get_studio_info", "arguments": {} } }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC response",
            "headers": {
              "RateLimit-Limit":     { "$ref": "#/components/headers/RateLimit-Limit" },
              "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
              "RateLimit-Reset":     { "$ref": "#/components/headers/RateLimit-Reset" },
              "RateLimit-Policy":    { "$ref": "#/components/headers/RateLimit-Policy" },
              "Idempotency-Key": {
                "description": "Echoed from the request Idempotency-Key header, if provided.",
                "schema": { "type": "string" }
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/JsonRpcResponse" }
              }
            }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      },
      "get": {
        "operationId": "getMcpServerInfo",
        "summary": "MCP server descriptor",
        "description": "Returns a JSON summary of the MCP server: name, version, protocol, transport, endpoint, tools, and resources.",
        "tags": ["MCP"],
        "responses": {
          "200": {
            "description": "Server descriptor",
            "headers": { "$ref": "#/components/headers/RateLimitBundle" },
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ApiError": {
        "type": "object",
        "required": ["error"],
        "description": "Standard JSON error envelope returned for all REST 4xx and 5xx responses.",
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code":    { "type": "string", "description": "Machine-readable error code.", "example": "METHOD_NOT_ALLOWED" },
              "message": { "type": "string", "description": "Human-readable error description.", "example": "Method not allowed. Allowed: GET, OPTIONS" },
              "details": { "type": "string", "description": "Optional additional context." }
            }
          }
        },
        "example": {
          "error": { "code": "METHOD_NOT_ALLOWED", "message": "Method not allowed. Allowed: GET, OPTIONS" }
        }
      },
      "ProblemDetails": {
        "type": "object",
        "description": "RFC 9457 Problem Details. Extension member `code` mirrors the NORTH28 machine-readable error constant.",
        "required": ["type", "title", "status", "detail", "code"],
        "properties": {
          "type":     { "type": "string", "format": "uri-reference", "example": "https://north28.studio/problems/method-not-allowed" },
          "title":    { "type": "string", "example": "Method Not Allowed" },
          "status":   { "type": "integer", "example": 405 },
          "detail":   { "type": "string", "example": "Method not allowed. Allowed: GET, OPTIONS" },
          "instance": { "type": "string", "format": "uri-reference" },
          "code":     { "type": "string", "description": "NORTH28 machine-readable error code.", "example": "METHOD_NOT_ALLOWED" }
        }
      },
      "StudioInfo": {
        "type": "object",
        "required": ["name", "description", "url", "contact"],
        "properties": {
          "name":        { "type": "string", "example": "NORTH28 STUDIO" },
          "description": { "type": "string" },
          "url":         { "type": "string", "format": "uri" },
          "contact":     { "type": "object", "properties": { "email": { "type": "string", "format": "email" } } },
          "slogan":      { "type": "string" }
        }
      },
      "Service": {
        "type": "object",
        "required": ["id", "title", "description"],
        "properties": {
          "id":          { "type": "string", "enum": ["strategy", "digital", "branding", "audiovisual"] },
          "title":       { "type": "string" },
          "description": { "type": "string" }
        }
      },
      "Page": {
        "type": "object",
        "required": ["name", "url"],
        "properties": {
          "name": { "type": "string" },
          "url":  { "type": "string", "format": "uri" }
        }
      },
      "PageMeta": {
        "type": "object",
        "required": ["hasMore"],
        "description": "Cursor pagination metadata.",
        "properties": {
          "nextCursor": {
            "type": ["string", "null"],
            "description": "Opaque cursor for the next page, or null when hasMore is false."
          },
          "hasMore": { "type": "boolean" }
        }
      },
      "PaginatedEnvelope": {
        "type": "object",
        "required": ["data", "page"],
        "description": "Cursor-paginated collection envelope (see info.x-rest-patterns.pagination).",
        "properties": {
          "data": { "type": "array", "items": {} },
          "page": { "$ref": "#/components/schemas/PageMeta" }
        }
      },
      "PaginatedPageList": {
        "type": "object",
        "required": ["data", "page"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Page" } },
          "page": { "$ref": "#/components/schemas/PageMeta" }
        }
      },
      "PaginatedServiceList": {
        "type": "object",
        "required": ["data", "page"],
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Service" } },
          "page": { "$ref": "#/components/schemas/PageMeta" }
        }
      },
      "AsyncJobSubmitRequest": {
        "type": "object",
        "required": ["operation", "payload"],
        "properties": {
          "operation": { "type": "string", "enum": ["batch"], "example": "batch" },
          "payload": { "$ref": "#/components/schemas/BatchRequest" }
        }
      },
      "AsyncJobAccepted": {
        "type": "object",
        "required": ["jobId", "status", "pollUrl"],
        "description": "Async job submission acknowledgement (202 Accepted).",
        "properties": {
          "jobId":   { "type": "string", "example": "eyJvcGVyYXRpb24iOiJiYXRjaCJ9" },
          "status":  { "type": "string", "enum": ["queued"], "example": "queued" },
          "pollUrl": { "type": "string", "format": "uri", "example": "https://north28.studio/api/jobs/eyJvcGVyYXRpb24iOiJiYXRjaCJ9" }
        }
      },
      "AsyncJobStatus": {
        "type": "object",
        "required": ["jobId", "status"],
        "description": "Async job polling response from GET /api/jobs/{jobId}.",
        "properties": {
          "jobId":  { "type": "string" },
          "status": {
            "type": "string",
            "enum": ["queued", "running", "completed", "failed"],
            "description": "Poll until status is completed or failed."
          },
          "result": { "description": "Present when status is completed." },
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "description": "Present when status is failed.",
            "properties": {
              "code":    { "type": "string" },
              "message": { "type": "string" },
              "details": { "type": "string" }
            }
          }
        }
      },
      "BatchRequestItem": {
        "oneOf": [
          { "type": "string", "enum": ["studio", "services", "pages"], "description": "Endpoint name shorthand." },
          {
            "type": "object",
            "required": ["endpoint"],
            "properties": {
              "endpoint": { "type": "string", "enum": ["studio", "services", "pages"] }
            }
          }
        ]
      },
      "BatchRequest": {
        "oneOf": [
          {
            "type": "object",
            "required": ["requests"],
            "properties": {
              "requests": {
                "type": "array",
                "items": { "$ref": "#/components/schemas/BatchRequestItem" },
                "minItems": 1,
                "maxItems": 10
              }
            }
          },
          {
            "type": "array",
            "items": { "$ref": "#/components/schemas/BatchRequestItem" },
            "minItems": 1,
            "maxItems": 10
          }
        ]
      },
      "BatchResponseItem": {
        "type": "object",
        "required": ["endpoint", "status"],
        "properties": {
          "endpoint": { "type": "string" },
          "status":   { "type": "integer", "example": 200 },
          "data":     { "description": "Response payload for successful items." },
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "description": "Per-item error when status is not 2xx (same fields as ApiError.error).",
            "properties": {
              "code":    { "type": "string" },
              "message": { "type": "string" },
              "details": { "type": "string" }
            }
          }
        }
      },
      "BatchResponse": {
        "type": "object",
        "required": ["responses"],
        "properties": {
          "responses": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/BatchResponseItem" }
          }
        }
      },
      "JsonRpcRequest": {
        "type": "object",
        "required": ["jsonrpc", "method"],
        "properties": {
          "jsonrpc": { "type": "string", "enum": ["2.0"] },
          "id":      { "oneOf": [{ "type": "string" }, { "type": "number" }, { "type": "null" }] },
          "method":  { "type": "string", "enum": ["initialize", "tools/list", "tools/call", "resources/list", "resources/read", "prompts/list"] },
          "params":  { "type": "object" }
        }
      },
      "JsonRpcError": {
        "type": "object",
        "required": ["code", "message"],
        "description": "JSON-RPC 2.0 error object (not used for REST 4xx/5xx).",
        "properties": {
          "code":    { "type": "integer", "description": "JSON-RPC error code.", "example": -32700 },
          "message": { "type": "string",  "description": "JSON-RPC error message.", "example": "Parse error" }
        }
      },
      "JsonRpcResponse": {
        "type": "object",
        "required": ["jsonrpc", "id"],
        "properties": {
          "jsonrpc": { "type": "string", "enum": ["2.0"] },
          "id":      { "oneOf": [{ "type": "string" }, { "type": "number" }, { "type": "null" }] },
          "result":  { "type": "object" },
          "error":   { "$ref": "#/components/schemas/JsonRpcError" }
        }
      },
      "NlwebAskRequest": {
        "type": "object",
        "required": ["query"],
        "description": "NLWeb conversational search request.",
        "properties": {
          "query":                  { "type": "string", "description": "Natural-language query.", "example": "what services does north28 offer?" },
          "decontextualized_query": { "type": "string", "description": "Reformulated, context-free version of the query (used when available instead of `query`)." },
          "site":                   { "type": "string", "description": "Restrict results to a specific site hostname.", "example": "north28.studio" },
          "streaming":              { "type": "boolean", "description": "Set `true` to receive results as an SSE stream (`text/event-stream`). Alternatively send `Accept: text/event-stream`.", "default": false },
          "prev":                   { "type": "array", "description": "Prior conversation turns for multi-turn context (reserved for future use).", "items": {} }
        }
      },
      "NlwebResult": {
        "type": "object",
        "required": ["url", "name", "score", "site", "description"],
        "description": "A single NLWeb search result.",
        "properties": {
          "url":         { "type": "string", "format": "uri", "description": "Canonical URL of the matching page or resource." },
          "name":        { "type": "string", "description": "Human-readable name of the result." },
          "score":       { "type": "number", "minimum": 0, "maximum": 1, "description": "Relevance score between 0 and 1." },
          "site":        { "type": "string", "description": "Hostname of the site that produced this result." },
          "description": { "type": "string", "description": "Short description of the result." },
          "answer":      { "type": "string", "description": "Direct answer extracted from the result, when available." }
        }
      },
      "NlwebAskResponse": {
        "type": "object",
        "required": ["query", "results"],
        "description": "NLWeb search response (non-streaming).",
        "properties": {
          "query":   { "type": "string", "description": "The query that was executed." },
          "results": { "type": "array", "items": { "$ref": "#/components/schemas/NlwebResult" } }
        }
      },
      "NlwebStreamEvent": {
        "type": "object",
        "required": ["type"],
        "description": "A single SSE event sent during a streaming NLWeb response. Each line is prefixed with `data: ` followed by a JSON object.",
        "discriminator": { "propertyName": "type" },
        "oneOf": [
          {
            "title": "result event",
            "allOf": [
              { "$ref": "#/components/schemas/NlwebResult" },
              { "properties": { "type": { "type": "string", "enum": ["result"] } }, "required": ["type"] }
            ]
          },
          {
            "title": "done event",
            "type": "object",
            "required": ["type", "query", "total"],
            "properties": {
              "type":  { "type": "string", "enum": ["done"] },
              "query": { "type": "string" },
              "total": { "type": "integer", "description": "Total number of result events emitted." }
            }
          }
        ]
      }
    },
    "parameters": {
      "ApiVersion": {
        "name": "API-Version",
        "in": "header",
        "required": false,
        "description": "Requested API version. Omit to use the latest stable version. Currently only `1` is available. Unrecognised values fall back to the latest stable version.",
        "schema": { "type": "string", "enum": ["1"], "default": "1", "example": "1" }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Maximum items to return per page (pagination pattern reference).",
        "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "example": 20 }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "Opaque cursor from a previous page (pagination pattern reference).",
        "schema": { "type": "string", "example": "eyJvZmZzZXQiOjIwfQ==" }
      }
    },
    "headers": {
      "X-API-Version": {
        "description": "API version that served this response.",
        "schema": { "type": "string", "example": "1" }
      },
      "RateLimit-Limit": {
        "description": "Maximum requests allowed in the current window (60 req / 60 s).",
        "schema": { "type": "integer", "example": 60 }
      },
      "RateLimit-Remaining": {
        "description": "Requests remaining in the current window.",
        "schema": { "type": "integer", "example": 59 }
      },
      "RateLimit-Reset": {
        "description": "Unix timestamp (seconds) when the current window resets.",
        "schema": { "type": "integer", "example": 1748460060 }
      },
      "RateLimit-Policy": {
        "description": "Rate-limit policy string (IETF draft-ietf-httpapi-ratelimit-headers).",
        "schema": { "type": "string", "example": "60;w=60" }
      },
      "Location": {
        "description": "Canonical URL of the async job resource to poll.",
        "schema": { "type": "string", "format": "uri", "example": "https://north28.studio/api/jobs/job_123" }
      },
      "Retry-After": {
        "description": "Suggested number of seconds before polling the job resource again.",
        "schema": { "type": "integer", "example": 5 }
      },
      "RateLimitBundle": {
        "description": "All four rate-limit headers.",
        "schema": { "type": "string" }
      }
    },
    "responses": {
      "AsyncJobAccepted": {
        "description": "Job accepted for asynchronous processing.",
        "headers": {
          "X-API-Version":       { "$ref": "#/components/headers/X-API-Version" },
          "Location":            { "$ref": "#/components/headers/Location" },
          "Retry-After":         { "$ref": "#/components/headers/Retry-After" },
          "RateLimit-Limit":     { "$ref": "#/components/headers/RateLimit-Limit" },
          "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
          "RateLimit-Reset":     { "$ref": "#/components/headers/RateLimit-Reset" },
          "Idempotency-Key": {
            "description": "Echoed from the request Idempotency-Key header, if provided.",
            "schema": { "type": "string" }
          }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/AsyncJobAccepted" },
            "example": {
              "jobId": "eyJvcGVyYXRpb24iOiJiYXRjaCJ9",
              "status": "queued",
              "pollUrl": "https://north28.studio/api/jobs/eyJvcGVyYXRpb24iOiJiYXRjaCJ9"
            }
          }
        }
      },
      "BadRequest": {
        "description": "Bad request — malformed JSON or missing required field.",
        "headers": {
          "X-API-Version":       { "$ref": "#/components/headers/X-API-Version" },
          "RateLimit-Limit":     { "$ref": "#/components/headers/RateLimit-Limit" },
          "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
          "RateLimit-Reset":     { "$ref": "#/components/headers/RateLimit-Reset" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" },
            "example": { "error": { "code": "BAD_REQUEST", "message": "Request body could not be parsed." } }
          },
          "application/problem+json": {
            "schema": { "$ref": "#/components/schemas/ProblemDetails" },
            "example": {
              "type": "https://north28.studio/problems/bad-request",
              "title": "Bad Request",
              "status": 400,
              "detail": "Request body could not be parsed.",
              "code": "BAD_REQUEST"
            }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "HTTP method not allowed on this endpoint.",
        "headers": {
          "X-API-Version":       { "$ref": "#/components/headers/X-API-Version" },
          "Allow": { "description": "Allowed HTTP methods.", "schema": { "type": "string", "example": "GET, OPTIONS" } },
          "RateLimit-Limit":     { "$ref": "#/components/headers/RateLimit-Limit" },
          "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
          "RateLimit-Reset":     { "$ref": "#/components/headers/RateLimit-Reset" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" },
            "example": { "error": { "code": "METHOD_NOT_ALLOWED", "message": "Method not allowed. Allowed: GET, OPTIONS" } }
          },
          "application/problem+json": {
            "schema": { "$ref": "#/components/schemas/ProblemDetails" },
            "example": {
              "type": "https://north28.studio/problems/method-not-allowed",
              "title": "Method Not Allowed",
              "status": 405,
              "detail": "Method not allowed. Allowed: GET, OPTIONS",
              "code": "METHOD_NOT_ALLOWED"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limit exceeded.",
        "headers": {
          "X-API-Version":       { "$ref": "#/components/headers/X-API-Version" },
          "RateLimit-Limit":     { "$ref": "#/components/headers/RateLimit-Limit" },
          "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
          "RateLimit-Reset":     { "$ref": "#/components/headers/RateLimit-Reset" },
          "RateLimit-Policy":    { "$ref": "#/components/headers/RateLimit-Policy" },
          "Retry-After": { "description": "Seconds to wait before retrying.", "schema": { "type": "integer", "example": 30 } }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" },
            "example": { "error": { "code": "RATE_LIMITED", "message": "Too many requests. Retry after the window resets." } }
          },
          "application/problem+json": {
            "schema": { "$ref": "#/components/schemas/ProblemDetails" },
            "example": {
              "type": "https://north28.studio/problems/rate-limited",
              "title": "Too Many Requests",
              "status": 429,
              "detail": "Too many requests. Retry after the window resets.",
              "code": "RATE_LIMITED"
            }
          }
        }
      },
      "InternalError": {
        "description": "Unexpected server error.",
        "headers": {
          "X-API-Version":       { "$ref": "#/components/headers/X-API-Version" },
          "RateLimit-Limit":     { "$ref": "#/components/headers/RateLimit-Limit" },
          "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
          "RateLimit-Reset":     { "$ref": "#/components/headers/RateLimit-Reset" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" },
            "example": { "error": { "code": "INTERNAL_ERROR", "message": "An unexpected error occurred." } }
          },
          "application/problem+json": {
            "schema": { "$ref": "#/components/schemas/ProblemDetails" },
            "example": {
              "type": "https://north28.studio/problems/internal-error",
              "title": "Internal Server Error",
              "status": 500,
              "detail": "An unexpected error occurred.",
              "code": "INTERNAL_ERROR"
            }
          }
        }
      },
      "NotFound": {
        "description": "Unknown API path.",
        "headers": {
          "X-API-Version":       { "$ref": "#/components/headers/X-API-Version" },
          "RateLimit-Limit":     { "$ref": "#/components/headers/RateLimit-Limit" },
          "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" },
          "RateLimit-Reset":     { "$ref": "#/components/headers/RateLimit-Reset" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ApiError" },
            "example": { "error": { "code": "NOT_FOUND", "message": "The requested API endpoint does not exist: /api/unknown" } }
          },
          "application/problem+json": {
            "schema": { "$ref": "#/components/schemas/ProblemDetails" },
            "example": {
              "type": "https://north28.studio/problems/not-found",
              "title": "Not Found",
              "status": 404,
              "detail": "The requested API endpoint does not exist: /api/unknown",
              "code": "NOT_FOUND"
            }
          }
        }
      }
    },
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Bearer token obtained from POST /oauth/token. Not required for current public endpoints."
      }
    }
  },
  "tags": [
    { "name": "Studio", "description": "REST endpoints for NORTH28 STUDIO data (public, no auth)" },
    { "name": "Jobs",   "description": "Async job submission (202) and polling" },
    { "name": "NLWeb",  "description": "NLWeb conversational search endpoint (nlweb.ai protocol)" },
    { "name": "MCP",    "description": "Model Context Protocol JSON-RPC endpoint" }
  ],
  "externalDocs": {
    "description": "Authentication guide",
    "url": "https://north28.studio/auth.md"
  }
}
