# Searching the documentation programmatically

`GET /api/public/docs/search?q=…` searches the public corpus and returns brief records — never page bodies. It needs no key and holds no session. Filters for `kind`, `status` and `channel` apply server-side, `limit` runs 1 to 25, and the same rate limit of 240 requests a minute per address covers every route under `/api/public/`.

- **Status:** Available
- **Audience:** developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/developers/docs-search-api/

## The request

```http
GET /api/public/docs/search?q=held+draft+approval&limit=5&status=available HTTP/1.1
Host: connectbyjbrh.com
```

| Name | Rule | Effect |
|---|---|---|
| `q` | Required, 2–200 characters | The query. Below two characters the request is rejected by validation, not answered emptily |
| `limit` | 1–25, default 10 | Result count. Values above 25 are refused; the index caps at 25 regardless |
| `kind` | Optional, ≤32 chars | Exact match on page kind — `capability`, `workflow`, `protocol`, `developer`, and the rest |
| `status` | Optional, ≤32 chars | Exact match on one of the five status words |
| `channel` | Optional, ≤32 chars | Keeps pages that list that channel: `email`, `phone`, `whatsapp`, `sms`, `softphone` |

Filters are applied after scoring rather than before, so a filter narrows the answer without changing the relative order of what survives. An empty result from a filtered search means nothing of that kind matched, not that the query was bad.

## The response

```json
{
  "query": "held draft approval",
  "count": 2,
  "results": [
    {
      "id": "C0142",
      "kind": "capability",
      "url": "https://connectbyjbrh.com/docs/email/held-drafts/",
      "markdown": "https://connectbyjbrh.com/docs/email/held-drafts/index.md",
      "title": "Held drafts",
      "description": "…",
      "summary": "A held draft is a finished reply that Connect wrote and did not send…",
      "status": "available",
      "audiences": ["both"],
      "channels": ["email"],
      "updated": "2026-09-10",
      "score": 18.412
    }
  ]
}
```

`summary` is the page's answer-first paragraph and is often enough to answer with. `score` is a relative number for ordering within one response — it is not a similarity, not a percentage, and not comparable between queries. Do not threshold on it.

> **Note** Search never returns a body. Choose a result, then call `/api/public/docs/page?ref=…` or fetch the `markdown` URL. The Arazzo workflow makes the same point in machine-readable form: the fetch step is not optional, because a summary is enough to choose a page and not enough to answer from.

## How ranking works, and why it behaves as it does

The index is a dictionary, not a search engine — a thousand short documents is the wrong size for one. Text is normalised, lowercased and split into tokens; single characters and a small stopword list are dropped. Title terms are indexed three times over, so a title match beats a description match.

- **Rare terms count for more.** A term's weight is the square root of the corpus size divided by the number of pages holding it.
- **An unknown term costs nothing.** It contributes zero rather than excluding everything, so a two-word query with one unfamiliar word still answers on the word it knows.
- **Prefixes match.** If nothing holds the exact term, pages holding a term that starts with it are used — which is how `idempot` finds *idempotency*.
- **Coverage beats intensity.** A page matching every term is multiplied ahead of one that matches a single rare term repeatedly; without that, multi-word product questions surface glossary pages.
- **Ties break on path**, so equal scores return in a stable order.

Page bodies are indexed at weight zero. That is what makes a question like "call recording" answerable: no page is titled after a capability that is not available, so without body indexing the honest page would be unfindable.

## The rest of the prefix, and its limits

| Route | Purpose |
|---|---|
| `GET /api/public/docs/search` | This one |
| `GET /api/public/docs/page?ref=…&body=true` | One page by URL, path or id; `ref` up to 300 characters |
| `GET /api/public/docs/list` | Filtered enumeration: `kind`, `status`, `channel`, `audience`, `limit` 1–1000 |
| `GET /api/public/docs/status` | Product and corpus status in one object |
| `GET /api/public/docs/manifest/{name}` | One generated manifest, with a guaranteed content type |

Every response carries `Cache-Control: public, max-age=300` and `Access-Control-Allow-Origin: *`, so a browser client can call these directly. Exceeding 240 requests a minute from one address returns 429 with `{"error": {"code": "rate_limited", …}}`; the limit is there to stop a loop rather than to ration access, and a documentation API that rate-limits a crawler into failure would defeat its own purpose.

> **Careful** This is not the application's API. The application's own OpenAPI document, `/docs` and `/redoc` are closed. Nothing under `/api/public/` reaches a workspace, there is no session to establish, and there is no write of any kind.

## Questions

### Why did a two-word query return a page that matches only one word?

Because an unrecognised term contributes nothing instead of filtering everything out. Ranking still favours the page that matches both, through the coverage multiplier, so the fuller match sits above the partial one rather than the partial one disappearing.

### Can I use `score` to decide whether to answer at all?

No. It is a within-response ordering number built from corpus statistics, and the same page can score differently for two phrasings of the same question. If you need a confidence signal, fetch the top page and judge the text.

### Does the search index include the changelog and the research notes?

It includes every page in the published corpus, and `kind` tells you which is which — filter on `kind=research` or `kind=developer` when you want one slice.

## Related

- [The public API](https://connectbyjbrh.com/developers/public-api/)
- [The machine-readable documentation](https://connectbyjbrh.com/developers/machine-manifests/)
- [Markdown alternates](https://connectbyjbrh.com/developers/markdown-mirrors/)
- [The Arazzo workflows](https://connectbyjbrh.com/developers/arazzo-workflows/)
- [API rate limits](https://connectbyjbrh.com/developers/api-rate-limits/)

## What this page is based on

- `backend/app/public_developer_api.py` — routes, validation, limits, headers
- `backend/app/public_docs.py` — `_Index.search`, `Doc.public`, MAX_RESULTS
- `webapp/developers/workflows.arazzo.yaml` — the search-then-fetch workflow
