Grounder › Docs

Tools & API reference

Grounder is three tools that ground any LLM in the live web - and nothing else. It returns evidence; your model writes the answer. This page documents every tool, every parameter, what it returns, and what it costs. Call them over MCP (any MCP client) or REST (any language).

Your key

Enter your email and we email you a key. No card, and no confirmation step - the key itself is the email, so receiving it is what proves the address is yours.

Entering your email again replaces your key. If you lose it, sign up with the same address and we'll send a new one - but the previous key stops working the moment we do, so update anywhere you had it. Your monthly page budget, your plan and your billing carry over untouched; only the key string changes. There is no way for us to show you an existing key, by design: this form takes an email address and no password, so anything it can show you, it can show anyone who knows your address.

Two ways to call

Base URL https://grounder.dev · Auth every request carries Authorization: Bearer gnd_live_….

1 · MCP (recommended for LLM clients)

A thin stdio server (grounder-mcp) that exposes the three tools to LM Studio, Ollama, Claude Desktop, Cursor, Continue.dev, Aider, or any MCP runtime. It just relays each call to grounder.dev with your key - the fetching and billing happen server-side, so it runs fine on a laptop. Add one block to your client's MCP config:

{
  "mcpServers": {
    "Grounder": {
      "command": "uvx",
      "args": ["grounder-mcp"],
      "env": { "GROUNDER_API_KEY": "gnd_live_your_key_here" }
    }
  }
}

Your model then calls web_search, fetch, and deep_search like any other tool. The MCP surface is intentionally minimal (the few parameters a model should reason about); the REST endpoints accept a couple more (noted per tool).

2 · REST (any language)

curl -X POST https://grounder.dev/v1/search \
  -H "Authorization: Bearer gnd_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"query": "latest stable Node.js LTS version", "region": "us"}'

Endpoints: POST /v1/search (web_search), POST /v1/fetch, POST /v1/deep_search. Every response includes a request_id for tracing, and the billed calls return X-Credits-Charged / X-Credits-Remaining headers - the count is in pages.

How pages work

Search Google and return the organic blue-link results - title, URL, snippet, domain - for a query, up to 10 results. Use it when you need current sources or URLs to read further (then call fetch). For a quick fact a snippet already answers, this alone is enough; to read a whole page, follow up with fetch.

Parameters

ParamTypeDefaultNotes
querystring-Required. 1-2048 chars. Natural language; quoted phrases work as on google.com.
regionstring"us"ISO-3166-1 alpha-2 lowercase (Google's gl) - e.g. us vs gb return different sites. Note: de/fr have DMA-driven citation-pool restrictions.
source"google" | "ddg""google"REST only. ddg = an anonymized search mode: the query is sent privately and never touches our servers, so it can't be tied back to you. Results can differ slightly from google.
pageinteger1REST only. 1-10, deeper into the SERP.

Example & response

curl -X POST https://grounder.dev/v1/search \
  -H "Authorization: Bearer gnd_live_…" -H "Content-Type: application/json" \
  -d '{"query": "best wireless headphones 2026", "region": "us"}'
{
  "query": "best wireless headphones 2026",
  "results": [
    { "position": 1,
      "title": "The Best Wireless Headphones for 2026",
      "url": "https://www.rtings.com/headphones/…",
      "domain": "www.rtings.com",
      "snippet": "Our top pick is the Sony WH-1000XM6…" }
  ],
  "results_count": 10,
  "meta": { "captcha": { "fired": false, "cost_usd": 0.0 }, "cookie_persistence": true },
  "request_id": "req_…"
}

If the search fails after retries, results comes back empty with a top-level unavailable_reason (captcha_failed | wait_timeout | transient_failure) instead of an exception - check results_count.

Using it well

fetch

Fetch the full, clean content of ONE web page as markdown - the actual current page (exact pricing, full docs, the complete article), fetched live. Handles most sites, including hard, protected pages that other tools return empty on. Use it to READ a result when the snippet isn't enough.

Scope rule. The url must be on a domain a recent web_search (or deep_search) by this key returned. fetch is for reading the sources you found, not arbitrary URLs. Out-of-scope → 403 url_not_in_scope. (Need to fetch any URL directly? That's the separate scraper API.)
Hard pages take longer. A hard, protected page can take up to ~a minute to fetch. Over MCP the client waits for it and returns the content, so you do nothing. Over REST you may get { "state": "fetching", "retry_after_seconds": 15 }; call fetch again with the same url until content returns. A fetching reply is free, and most pages return on the first call.

Parameters

ParamTypeDefaultNotes
urlstring-Required. 1-2048 chars. Must be a URL a recent search returned (scope rule).
cleanbooleantruetrue → clean markdown in content. false → raw HTML in html.
querystringnullOptional, ≤2048 chars. If set, the response ALSO returns passages: the few verbatim slices most relevant to this query (bge-small rerank), each with a score. content is still returned - passages is additive.
max_tokensintegernullWith query only: cap passages to ~this many tokens (est. ~4 chars/token) so they fit your window. 100-8000. No effect without query.

Example & response

curl -X POST https://grounder.dev/v1/fetch \
  -H "Authorization: Bearer gnd_live_…" -H "Content-Type: application/json" \
  -d '{"url": "https://www.rtings.com/headphones/…",
       "query": "best noise-cancelling pick", "max_tokens": 600}'
{
  "success": true, "status": 200, "tier": 1,
  "content": "# The Best Wireless Headphones for 2026\n\nOur top pick is…",
  "content_chars": 6213,
  "passages": [ { "text": "For noise cancelling, the Sony WH-1000XM6…", "score": 0.82 } ],
  "cached": false, "elapsed_ms": 180, "request_id": "req_…"
}

Tiers. tier is the cost class that succeeded, from 1 (an easy page) up to 3 (a hard, protected page). Your plan caps which tiers you can reach; a Free key never spends a tier-3 fetch - a hard page returns 402 upgrade_required instead, so you're never silently charged for one.

Using it well

Answer a question in ONE call that needs reading and comparing MULTIPLE pages. It runs a search, fetches several of the top result pages, ranks passages across all of them, decides internally when it has read enough, and returns the few verbatim slices most relevant to your question. It returns evidence, not a written answer: your model reads the passages and composes the answer - and decides whether they actually answer the question, because that depends on what you are really asking, not on us. Don't use it for a quick fact a single snippet answers (use web_search) or to read one known URL (use fetch).

Parameters

ParamTypeDefaultNotes
querystring-Required. 1-2048 chars. The question, in natural language.
max_fetchesinteger6Hard cap on pages read (cost + latency bound). 1-12. Counts as 1 page per page actually read, up to this.
max_tokensinteger600Cap the returned pack to ~this many tokens so it fits your window (~4 chars/token). 100-4000.
source"google" | "ddg""google"REST only. Same anonymized tier as web_search.

Example & response

curl -X POST https://grounder.dev/v1/deep_search \
  -H "Authorization: Bearer gnd_live_…" -H "Content-Type: application/json" \
  -d '{"query": "which production databases use the Raft consensus algorithm?",
       "max_fetches": 6, "max_tokens": 600}'
{
  "query": "which production databases use the Raft consensus algorithm?",
  "passages": [
    { "text": "YugabyteDB uses Raft for leader election and data replication…",
      "score": 0.84, "url": "https://www.yugabyte.com/…" }
  ],
  "pages_fetched": 2,          // pages that returned real content (billed)
  "pages_attempted": 3,        // incl. pages we couldn't fetch, skipped (free)
  "retrieval_relevance": 0.84, // best passage's similarity to the query - a coverage hint
  "stop_reason": "answered",   // why the fan-out stopped: answered | page cap reached | search results exhausted
  "request_id": "req_…"
}

Each passage carries its source url so your model can cite it. Grounder does not return a SUPPORTED / NOT_FOUND verdict: whether the evidence answers a question depends on how you phrased it, which only your model knows, so it reads the passages and decides. retrieval_relevance and stop_reason are coverage hints, not a verdict. If the passages don't contain the answer, have your model say so rather than guess.

Using it well

Errors

Errors return { "error": { "code", "message", "request_id" } } with an HTTP status. The code is stable - branch on it.

CodeHTTPMeaning & what to do
invalid_request400 / 422Bad/missing parameter (e.g. query > 2048 chars). Fix the input.
invalid_api_key401Missing or unknown key. Check Authorization: Bearer gnd_live_….
quota_exceeded402Out of your monthly pages. Wait for the reset or upgrade.
upgrade_required402The call is valid, but your plan can't fetch this one - a hard, protected page needs a paid plan. Upgrade.
url_not_in_scope403fetch only: the URL's domain wasn't returned by a recent search by this key. Search first.
rate_limited429Per-key rate limit hit. Back off and retry (the bucket refills at your plan's per-minute rate).
fetch_failed422The page couldn't be fetched (blocked or unreachable). Not charged. If the domain has refused every tier on recent attempts we stop buying attempts and say so - it is re-tried automatically once a day, so a retry loop won't help but coming back tomorrow will.
internal_error500 / 502 / 503Our backend was briefly unavailable. Retry with backoff. This is the only family where a 5xx means us - a page we simply could not read is a 422, not a 502.
google_rate_limited · captcha_failed · timeout-Transient search-side conditions on web_search/deep_search. Retry with backoff.

Getting a written answer (optional)

deep_search returns evidence, not prose: ranked verbatim passages, each with its source URL, so you compose the answer and can always check it. Pass include_answer: true and the response also carries answer - a short answer written from those same passages by a 70B model.

It is additive and free: passages come back either way, so the answer never replaces the evidence it was built from, and you can check one against the other. If the passages don't support an answer the field is simply omitted rather than filled with a guess. Costs no extra pages.

curl -s https://grounder.dev/v1/deep_search \
  -H "Authorization: Bearer gnd_live_your_key_here" -H "Content-Type: application/json" \
  -d '{"query": "what does R2 charge for class A operations", "include_answer": true}'

Rate limits & plans

Each plan sets a monthly page budget, per-tool rate limits (a token bucket - a burst capacity plus a sustained per-minute refill, sized so an agent's fan-out completes instead of 429-ing halfway), and caps on the harder fetches. Hard-fetch is a paid-plan feature; deep_search is free to try (rate-limited on Free) and runs at full speed on paid plans. See the full pricing page, and query /v1/plans for the exact limits the gate is enforcing right now.

Recipes

Ground a local model, capped to its window

import requests
KEY = "gnd_live_…"
pack = requests.post("https://grounder.dev/v1/deep_search",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"query": "latest stable Node.js LTS version", "max_tokens": 600},
    timeout=180).json()
evidence = "\n\n".join(f'[{p["url"]}] {p["text"]}' for p in pack["passages"])
# hand `evidence` to your local model (it fits an 8k window) with an instruction like:
# "Answer only from the sources below; if they don't contain it, say you couldn't verify it."

Read one page for an exact fact

hits = requests.post("https://grounder.dev/v1/search",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"query": "OpenAI API pricing"}).json()["results"]
page = requests.post("https://grounder.dev/v1/fetch",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"url": hits[0]["url"], "query": "price per million input tokens",
          "max_tokens": 400}).json()
print(page["passages"])          # only the pricing lines, not the whole page

Search without touching our infrastructure

requests.post("https://grounder.dev/v1/search",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"query": "site:reddit.com best budget mechanical keyboard",
          "source": "ddg"})   # sent anonymously, not from our servers

Get a key and try it

Free tier: 1,500 calls a month, email only, no card. Drop it into your MCP config and your model can search and read the live web.

Get a free key