Site search: one box for everything a site publishes

Embed
Preview of the card
<a href="https://forumulate.com/guides/site-search"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/853e4d38-255e-4416-b080-4b6e23a9c43b/social-card.svg?v=3e7ae33eec8b" alt="Site search: one box for everything a site publishes" width="600" height="315"></a>

A site's search covers every space the site publishes — docs and API reference pages, blog posts, forum topics, changelog releases, roadmap ideas, legal documents, status incidents and events (when an events space opts in) — in one ranked list. Use it from the site's own search box, or build your own UI on it: every hit says what it is, where it lives on your domain, and how to get there.

GET  /public/orgs/{orgId}/sites/{slug}/search?q=payouts
GET  /public/orgs/{orgId}/sites/{slug}/search/suggest?q=pay
POST /public/orgs/{orgId}/sites/{slug}/search/answer      { "q": "How are payouts paid?" }
POST /public/orgs/{orgId}/sites/{slug}/search-events      { "query": "payouts", "hits": 3 }

All four are anonymous, so the first is a public search endpoint for your docs that an agent can call without a key; /llms-quickstart.txt points at it (see robots.txt and llms.txt). A signed-in reader's session (or a token) widens the search to the private member spaces their role may read — never because a space is in the site — and that answer is then private, no-store.

The answer

{
  "hits": [
    {
      "id": "8c1…",
      "kind": "page",
      "title": "Payouts",
      "snippet": "How referral <b>payouts</b> are paid each month.",
      "url": "https://acme.com/docs/payouts",
      "breadcrumb": [
        { "label": "Acme", "url": "https://acme.com" },
        { "label": "Docs", "url": "https://acme.com/docs" },
        { "label": "Payouts", "url": "https://acme.com/docs/payouts" }
      ],
      "spaceId": "…", "spaceSlug": "docs", "spaceName": "Docs", "spaceType": "DOCS",
      "slug": "payouts", "pageId": "8c1…", "collectionId": "…",
      "rank": 0.42, "updatedAt": "2026-10-20T09:12:00.000Z"
    }
  ],
  "facets": { "page": 1, "reference": 0, "post": 0, "topic": 0, "release": 0, "idea": 0, "legal": 0, "incident": 0, "event": 0 },
  "total": 1,
  "offset": 0,
  "minQueryLength": 3
}
  • kind — page, reference (a page generated from an OpenAPI document by an import, a resync or the generator, or given the reference shape), post, topic, release, idea, legal, incident, event.

  • url — absolute. The space's own custom domain when it has one, else the site's domain plus the space's segment, else our reader path. Every URL is an address that answers 200.

  • snippet — HTML-escaped text; the only tag is <b> around a match.

  • breadcrumb — the site, the space, the collection a page sits in (when it is not the space's only one), the hit. The site's crumb is on the same address as the hits: the site's own domain, else the custom domain its spaces answer on, and our /s/ path only when none of them has one — a white-label site never gets a forumulate.com link in its breadcrumb.

  • facets — matches per kind, for filter chips.

  • corrected — present when the query found nothing as typed and a corrected one was run (below).

Filters and paging

Parameter

Meaning

types=post,topic

Only these kinds. An unknown kind is a 422 listing the legal ones.

spaces=docs,community

Only these member spaces, by slug or id. A space the site does not search is a 422 naming the ones it does.

limit, offset

20 by default, 50 at most; offset up to 1,000.

locale=de

Prefer that translation of each page.

answer=true

Add a grounded answer (below).

A term shorter than the organization's search.min_search_term_length answers no hits and states minQueryLength, rather than an error under a reader's cursor.

Typos

When a query finds nothing as typed, each word of four letters or more is corrected by one edit (a swapped pair counts as one) against the words the site actually uses — its titles, topic and category names — and the corrected query is run. payuot finds Payouts; cat is never turned into car. The answer says what changed: "corrected": { "query": "payouts", "from": ["payuot"], "to": ["payouts"] }.

Off with the org setting search.typo_tolerance, or per site with config.searchSettings.typoTolerance: false (PATCH /orgs/{orgId}/sites/{siteId}).

Caching

An anonymous answer is Cache-Control: public, s-maxage=60, so a CDN in front of your custom UI keeps it for a minute. Change it with search.site_cache_seconds (org) or config.searchSettings.cacheSeconds (site); 0 sends private, no-store. An answer shaped by a reader's credential, and any answer carrying a grounded answer, is always private, no-store.

Suggestions

GET …/search/suggest?q=pay answers the best few titles — { id, kind, title, url, spaceName } — from the same spaces, filters and typo tolerance, for a type-ahead list. Up to 10 (limit, default 8).

Recording searches: search-events

The search GET records nothing. A search box reports a settled search with the beacon, once the reader's consent allows analytics:

POST /public/orgs/{orgId}/sites/{slug}/search-events
{ "query": "payouts", "hits": 3, "clickedUrl": "https://acme.com/docs/payouts", "spaceId": "…" }

It lands in the space analytics and the knowledge-gap report ("searches with no results") as a search event with source: "site", counted in the clicked hit's space (else the site's first member). 204, nothing returned. So a reader who refused measurement is never in the report.

A grounded answer

Off by default. Turn it on with the org setting search.site_answer (or a site's config.searchSettings.answer):

  • byok — your organization's own AI key (PUT /orgs/{orgId}/ai/provider). There is no platform key; without yours the answer says unavailable.

  • provider — the answer service you connect below.

?answer=true on the search, or POST …/search/answer { q, types?, spaces? }, adds:

"answer": {
  "state": "answered",
  "provider": "byok",
  "answer": "Payouts are paid on the 1st of each month…",
  "citations": [{ "id": "8c1…", "kind": "page", "title": "Payouts", "url": "https://acme.com/docs/payouts" }]
}

state is answered, unanswered, off, unavailable (nothing connected, with detail) or error (the provider failed, with detail). The answer is grounded on the hits this reader's search returned and nothing else, and a citation is kept only when it names one of them.

Bring your own answer service

Any service that speaks one small contract can answer — Chatly is a preset; a RAG service of your own, or any vendor behind a small adapter, is custom.

PUT /orgs/{orgId}/search/answer-provider
{ "preset": "custom", "url": "https://answers.example.com/forumulate", "secret": "…" }
GET    /orgs/{orgId}/search/answer-provider
POST   /orgs/{orgId}/search/answer-provider/test
DELETE /orgs/{orgId}/search/answer-provider

We POST to your URL:

{
  "version": 1,
  "question": "How are payouts paid?",
  "locale": "en",
  "site": { "id": "…", "slug": "hub", "name": "Acme", "orgId": "…" },
  "sources": [
    { "ref": "s1", "kind": "page", "title": "Payouts", "url": "https://acme.com/docs/payouts", "text": "…" }
  ]
}

and read { "answer": "…", "answered": true, "citations": ["s1"] }.

  • Authentication. The secret is sent as Authorization: Bearer <secret>, or as <headerName>: <secret> with auth: "header".

  • Signature. Every request carries Forumulate-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>, the same scheme as our webhooks.

  • Limits. timeoutMs (1–15 s, default 8 s), maxSources (1–12, default 6). The URL must be https and public; a preset holds it to the vendor's domain (chatly: *.chatlychat.com).

  • Failures never fail the search: the answer says error with the reason, and GET …/answer-provider shows lastError and lastUsedAt.

  • The secret is write-only; secretHint shows its last four characters. Send the hint back unchanged (or omit secret) to keep the stored one.

Per-site settings

config.searchSettings on a site (PATCH /orgs/{orgId}/sites/{siteId}). config.search is the header search control (none | icon | pill), the same key as a space's chrome.search; an object sent there is read as these settings and moved to searchSettings, with a note on X-Field-Warnings.

Key

Unset falls back to

typoTolerance

search.typo_tolerance (on)

answer

search.site_answer (off)

cacheSeconds

search.site_cache_seconds (60)

types

every kind

excludeSpaceIds

none — a listed member space stays published and in the nav, but out of search

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.

Answer Provider

Operation

Request

What it does

Shortcuts

AnswerProvider_get

GET /orgs/{orgId}/search/answer-provider

The connected answer provider

client.answerProvider.get()

AnswerProvider_set

PUT /orgs/{orgId}/search/answer-provider

Connect an answer provider for site search

client.answerProvider.set()

AnswerProvider_patch

PATCH /orgs/{orgId}/search/answer-provider

Change part of the answer provider (the secret is kept unless sent)

client.answerProvider.patch()

AnswerProvider_remove

DELETE /orgs/{orgId}/search/answer-provider

Disconnect the answer provider

client.answerProvider.remove()

AnswerProvider_test

POST /orgs/{orgId}/search/answer-provider/test

Send the answer provider one sample question

client.answerProvider.test()

Operation

Request

What it does

Shortcuts

SiteSearch_suggest

GET /public/orgs/{orgId}/sites/{slug}/search/suggest

Type-ahead suggestions for a published site

client.sites.search.suggest()

SiteSearch_ask

POST /public/orgs/{orgId}/sites/{slug}/search/answer

A grounded answer from a published site

client.sites.search.ask()

SiteSearch_event

POST /public/orgs/{orgId}/sites/{slug}/search-events

Record a settled site search (consent-gated beacon)

client.sites.search.event()

The Forumulate teamUpdated