Skip to content

Search

One search finds anything in a business. The app’s search box, GET /search and the search tool over MCP are the same search.

GET /search?q=kingston&limit=5
Authorization: Bearer btl_…
Parameter What it is
q What to look for. Two characters at least, a hundred at most.
kinds Optional. Only these, comma separated, from the list below.
limit Optional. The most results of each kind, 1 to 20. 5 if left out.

The answer is a single list, best first across every kind:

{
"items": [
{
"kind": "customer",
"id": "0192…",
"title": "Kingston Fish Bar",
"detail": "KT1 1QN",
"status": null,
"date": null,
"archived": false,
"path": "/customers/0192…",
"score": 0.9
}
]
}

path is the page in the app that shows the record. score runs from 0 to 1 and compares across kinds, so the best customer and the best invoice can be put in order against each other.

The route needs tenant:read. Each kind then needs the scope that reads it anywhere else. A kind the caller cannot read is left out of the answer, not refused, so the same call works for an owner, a driver and a narrow key.

Kind Matched on Needs
customer Trading and legal name, email, phone customers:read
address Name, street, town, postcode with and without the space customers:read
order Tracking reference, the shop’s reference, a one-off’s name and address orders:read
invoice Number as printed, number alone, who it is billed to invoices:read
product Name, short code, SKU products:read
person Name, email members:read
vehicle Name, registration with and without the space rounds:write
base Name, street, town, postcode rounds:write

Vehicles and bases need rounds:write because rounds:read alone is a driver reading their own round, and the fleet is the office’s.

What was typed is split into words on spaces and hyphens. Every word must appear somewhere in the record’s text, in any order and any case. Splitting on hyphens is what lets 7K3M-9QWX find a reference stored as 7K3M9QWX, and INV-BTL00042 find invoice 42.

A word of four letters or more also matches when it is close to a word in the record (trigram word similarity), which forgives a slip of the finger. Shorter words must match as typed.

A phone number is reduced to the same digits Bottle keeps a customer’s phone as, so +44 20 8546 1234 and 020 8546 1234 find the same customer. A number on its own is also tried as an invoice number.

Ranking, the same for every kind: the name exactly, then the name starting with what was typed, then a word in the name starting with it, then it appearing anywhere, then it only looking like it. Archived records drop by a quarter.

Search runs in Postgres, not a separate search engine. Every kind with more than a handful of rows per business has a GIN trigram index (pg_trgm) over the text it is matched on, with the business first in the index (btree_gin). A search reads one business’s rows and never scans another’s, however many businesses share the database.

Each kind is its own small query, run side by side, so one kind never holds up the rest. The search is always up to date: there is no second copy to keep in step, and nothing to rebuild.

People, vehicles and bases have no index of their own. A business has tens of them, and reading its own is already quick.

Both extensions ship with Postgres, and with Amazon RDS and Aurora, so the search moves with the database. If a single business ever outgrows it, the search sits behind one repository in the API (apps/api/src/repositories/search.ts), and moving it to a dedicated engine changes that class and nothing that calls it.

The index expressions in migration 0089_search.sql and the expressions in the repository must stay the same. If they drift, Postgres stops using the index and the search still works, only slower.