{
  "openapi": "3.1.0",
  "info": {
    "title": "Snaga Search API",
    "version": "1.0.0",
    "summary": "Web search for AI agents: links and what each page says about itself.",
    "description": "Snaga searches its own index of the web. One endpoint returns ranked links with the title, the description, the language, the crawl date and the query words each page matched.\n\nResults are not filtered by topic: an agent gets what the web has, adult content included, except what law or site owners keep out.\n\n`GET /site/search` is the same engine without a key: ten results at a time, thirty searches a minute from one address, and no account.\n\nThe same API speaks Model Context Protocol at `POST /mcp`, and two endpoints have the shape Ollama gives them, so a client written for Ollama can be pointed here instead.",
    "contact": {
      "name": "Snaga",
      "email": "agent@snaga.ai",
      "url": "https://search.snaga.ai/api"
    }
  },
  "servers": [
    { "url": "https://search.snaga.ai", "description": "The public API" }
  ],
  "security": [
    { "bearerAuth": [] }
  ],
  "tags": [
    { "name": "search", "description": "Finding pages" },
    { "name": "account", "description": "What a key has used" },
    { "name": "ollama", "description": "The endpoints Ollama has" },
    { "name": "mcp", "description": "The search as a tool for a model" }
  ],
  "paths": {
    "/v1/search": {
      "get": {
        "tags": ["search"],
        "summary": "Search the index",
        "description": "Returns ranked pages for a query.",
        "operationId": "search",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "The query, up to 500 characters. Understands \"exact phrase\", -word, site: and lang:.",
            "schema": { "type": "string", "maxLength": 500 }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "How many results, 1 to 50. Ten by default.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Only pages in this language, ISO 639-1, e.g. en, uk, de.",
            "schema": { "type": "string", "minLength": 2, "maxLength": 3 }
          },
          {
            "name": "site",
            "in": "query",
            "required": false,
            "description": "Only pages of this registered domain, e.g. python.org.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "The answer. The rate headers come with every answer; the quota headers come with a key on the free plan, and the credit header with a key on prepaid credit.",
            "headers": {
              "X-RateLimit-Limit": { "description": "Requests per minute", "schema": { "type": "integer" } },
              "X-RateLimit-Remaining": { "description": "Requests left this minute", "schema": { "type": "integer" } },
              "X-Quota-Limit": { "description": "Free plan: the month's requests", "schema": { "type": "integer" } },
              "X-Quota-Remaining": { "description": "Free plan: what is left after this request", "schema": { "type": "integer" } },
              "X-Credit-Remaining-Usd": { "description": "Pay as you go: the credit left after this request", "schema": { "type": "number" } }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SearchResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/SearchFailed" }
        }
      }
    },
    "/site/search": {
      "get": {
        "tags": ["search"],
        "summary": "Search the index without a key",
        "description": "The same engine, without a key: ten results at a time, thirty searches a minute per address, and no account. This is the endpoint the public page uses. The results carry no score and no matched words, and they come in pages of ten.",
        "operationId": "siteSearch",
        "security": [],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "The query, up to 500 characters. Understands \"exact phrase\", -word, site: and lang:.",
            "schema": { "type": "string", "maxLength": 500 }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Only pages in this language, ISO 639-1, e.g. en, uk, de.",
            "schema": { "type": "string", "minLength": 2, "maxLength": 3 }
          },
          {
            "name": "news",
            "in": "query",
            "required": false,
            "description": "Only pages crawled in the last fourteen days, when 1.",
            "schema": { "type": "integer", "enum": [1] }
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "Which ten results to return, from 1. The order does not change from page to page.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 5, "default": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "The answer",
            "headers": {
              "Retry-After": { "description": "Seconds to wait, when the rate is over", "schema": { "type": "integer" } },
              "X-RateLimit-Limit": { "description": "Searches a minute from one address", "schema": { "type": "integer" } },
              "X-RateLimit-Remaining": { "description": "Searches left in this minute", "schema": { "type": "integer" } }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SiteSearchResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/SearchFailed" }
        }
      }
    },
    "/v1/usage": {
      "get": {
        "tags": ["account"],
        "summary": "What this key has used",
        "description": "Free, and it does not count against the rate limit.",
        "operationId": "usage",
        "responses": {
          "200": {
            "description": "The account",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Usage" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": {
            "description": "The API is open: there are no keys and no usage to report.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    },
    "/v1/status": {
      "get": {
        "tags": ["account"],
        "summary": "What the index holds",
        "description": "The number of pages, where they come from, and pages per language. Free, and it needs no key.",
        "operationId": "status",
        "security": [],
        "responses": {
          "200": {
            "description": "The index",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Status" }
              }
            }
          }
        }
      }
    },
    "/api/web_search": {
      "post": {
        "tags": ["ollama"],
        "summary": "Search, as Ollama has it",
        "description": "The same engine, in the shape Ollama gives its own endpoint, so a client written for Ollama can be pointed here instead.",
        "operationId": "ollamaWebSearch",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "query": { "type": "string", "description": "The query." },
                  "max_results": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5 }
                },
                "required": ["query"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The results",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OllamaResults" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/web_fetch": {
      "post": {
        "tags": ["ollama"],
        "summary": "Fetch one page, as Ollama has it",
        "description": "Fetches a page, after checking its robots.txt, and returns its main content as Markdown.",
        "operationId": "ollamaWebFetch",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": { "type": "string", "format": "uri", "description": "The page to fetch." }
                },
                "required": ["url"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The page",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OllamaPage" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/mcp": {
      "post": {
        "tags": ["mcp"],
        "summary": "The search as a tool for a model",
        "description": "Model Context Protocol over streamable HTTP: one JSON-RPC 2.0 message in, its answer out. The methods are `initialize`, `ping`, `tools/list` and `tools/call`; a notification is answered with 202 Accepted and no body. The tool `web_search` takes `query`, `max_results` (1–50, default 8; `limit` is taken as well), `lang` and `site`. When the server fetches it also offers `web_fetch`, which takes `url` and gives the page's title, its content as Markdown and the links it holds. A call is billed like a search; the protocol's own messages are not billed but count towards the rate limit.",
        "operationId": "mcp",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "jsonrpc": { "type": "string", "enum": ["2.0"] },
                  "id": { "type": ["integer", "string", "null"], "description": "The number the answer will carry back." },
                  "method": { "type": "string", "examples": ["tools/call"], "description": "What to do: `initialize`, `tools/list`, `tools/call`." },
                  "params": { "type": "object", "description": "The arguments of the method." }
                },
                "required": ["jsonrpc", "method"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The answer to the message",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/McpReply" }
              }
            }
          },
          "202": { "description": "The message was a notification; there is nothing to answer." },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Your key, which begins with ws_. `X-API-Key: ws_…` works as well. Write to agent@snaga.ai to get one: the free plan gives 5,000 requests a month and 60 a minute; prepaid credit is $1 per 1,000 requests and 600 a minute."
      }
    },
    "schemas": {
      "SearchResponse": {
        "type": "object",
        "required": ["query", "results", "took_ms"],
        "properties": {
          "query": { "type": "string" },
          "took_ms": { "type": "number", "description": "How long the search took, in milliseconds, with the fraction when the search was that quick." },
          "no_words": { "type": "boolean", "description": "The query was made of operators only, such as site: or lang:, and had no words to look for. It is left out when the query had words." },
          "results": { "type": "array", "items": { "$ref": "#/components/schemas/Result" } }
        }
      },
      "Result": {
        "type": "object",
        "required": ["url", "title", "site", "crawled", "score", "matched"],
        "properties": {
          "url": { "type": "string", "format": "uri", "description": "The page; its canonical URL when the page names one on the same host." },
          "title": { "type": "string", "description": "What the page calls itself." },
          "description": { "type": "string", "description": "The page's meta description: what it says about itself." },
          "site": { "type": "string", "description": "The site the page belongs to, e.g. python.org, or user.github.io on a shared platform." },
          "site_rank": { "type": "integer", "description": "The site's position in our ranking of how much the web links to it; lower is more linked-to. Missing for unranked sites." },
          "lang": { "type": "string", "description": "The page's language, when known." },
          "crawled": { "type": "string", "format": "date", "description": "When the page was crawled." },
          "score": { "type": "number", "description": "Relevance, comparable only within one answer." },
          "matched": { "type": "array", "items": { "type": "string" }, "description": "Query words the page has, as written or as another form of the word." },
          "missing": { "type": "array", "items": { "type": "string" }, "description": "Distinctive query words the page lacks; shown only for partial matches." },
          "excerpt": { "type": "string", "description": "The sentences of the page's text that best match the query." }
        }
      },
      "SiteSearchResponse": {
        "type": "object",
        "required": ["query", "page", "pages", "total", "capped", "server_time_ms", "results"],
        "properties": {
          "query": { "type": "string" },
          "page": { "type": "integer", "description": "The page returned, from 1." },
          "pages": { "type": "integer", "description": "How many pages of ten there are." },
          "total": { "type": "integer", "description": "Results the engine found, at most fifty." },
          "capped": { "type": "boolean", "description": "Whether there were more matches than fifty." },
          "server_time_ms": { "type": "number", "description": "How long the search took on the server, in whole milliseconds." },
          "no_words": { "type": "boolean", "description": "The query was made of operators only, such as site: or lang:, and had no words to look for. It is left out when the query had words." },
          "results": { "type": "array", "items": { "$ref": "#/components/schemas/SiteResult" } }
        }
      },
      "SiteResult": {
        "type": "object",
        "required": ["url", "title", "site"],
        "properties": {
          "url": { "type": "string", "format": "uri", "description": "The page." },
          "title": { "type": "string", "description": "What the page calls itself." },
          "description": { "type": "string", "description": "The page's meta description: what it says about itself." },
          "site": { "type": "string", "description": "The site the page belongs to, e.g. python.org." },
          "excerpt": { "type": "string", "description": "The sentences of the page's text that best match the query." },
          "crawled": { "type": "string", "format": "date", "description": "When the page was crawled." },
          "missing": { "type": "array", "items": { "type": "string" }, "description": "The query words the page does not have, when the result is a partial match. It is left out when the page has every word." }
        }
      },
      "Usage": {
        "type": "object",
        "required": ["key", "plan", "rate_per_min", "month", "requests_this_month", "spent_usd"],
        "properties": {
          "key": { "type": "string" },
          "plan": { "type": "string", "enum": ["free", "payg", "enterprise"] },
          "rate_per_min": { "type": "integer" },
          "month": { "type": "string", "description": "YYYY-MM, UTC." },
          "requests_this_month": { "type": "integer" },
          "free_requests_left": { "type": "integer", "description": "Free plan only: what is left this month." },
          "credit_left_usd": { "type": "number", "description": "Pay as you go only: the credit left." },
          "spent_usd": { "type": "number" }
        }
      },
      "Status": {
        "type": "object",
        "required": ["pages", "source", "languages"],
        "properties": {
          "pages": { "type": "integer" },
          "source": { "type": "string", "description": "Where the pages come from." },
          "languages": {
            "type": "array",
            "description": "Pages per language, most first.",
            "items": { "type": "array", "items": { "type": ["string", "integer"] } }
          }
        }
      },
      "OllamaResults": {
        "type": "object",
        "required": ["results"],
        "properties": {
          "no_words": { "type": "boolean", "description": "The query was made of operators only, such as site: or lang:, and had no words to look for. It is left out when the query had words." },
          "results": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["title", "url", "content"],
              "properties": {
                "title": { "type": "string" },
                "url": { "type": "string", "format": "uri" },
                "content": { "type": "string", "description": "What the page says that matches the query." }
              }
            }
          }
        }
      },
      "OllamaPage": {
        "type": "object",
        "required": ["title", "content", "links"],
        "properties": {
          "title": { "type": "string" },
          "content": { "type": "string", "description": "The page's main content, as Markdown." },
          "links": { "type": "array", "items": { "type": "string" } }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error", "message"],
        "properties": {
          "error": { "type": "string", "description": "A short code, e.g. rate_limited." },
          "message": { "type": "string", "description": "What to do about it." }
        }
      },
      "McpReply": {
        "type": "object",
        "required": ["jsonrpc"],
        "properties": {
          "jsonrpc": { "type": "string" },
          "id": { "type": ["integer", "string", "null"], "description": "The number the question carried." },
          "result": {
            "type": "object",
            "description": "What the method gave. A tool call answers with the results as text and, for `web_search`, with `structuredContent.results` — the same fields as a search answer.",
            "properties": {
              "content": { "type": "array", "items": { "type": "object" } },
              "structuredContent": { "type": "object" },
              "isError": { "type": "boolean" }
            }
          },
          "error": {
            "type": "object",
            "description": "The method failed: -32700 a message we could not read, -32601 a method we do not have, -32602 arguments we could not use.",
            "properties": {
              "code": { "type": "integer" },
              "message": { "type": "string" }
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The query is missing or too long.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "No key, or an unknown or revoked one.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "PaymentRequired": {
        "description": "The free plan's requests are used up, or there is not enough credit.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Over the requests per minute. Wait the seconds in Retry-After.",
        "headers": {
          "Retry-After": { "description": "Seconds to wait", "schema": { "type": "integer" } }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "SearchFailed": {
        "description": "Our fault. Retrying is safe.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  }
}
