How AI-agent search works over an MCP CRM
What your AI actually gets: deterministic Postgres full-text search, scoped to the records it may see before matching, and consistent the instant you write.
When you connect your AI to Historis over MCP and ask "which clients were interested in winter coats?" or "what did I tell Marie last month?", something has to turn that into an answer in a single step. That something is search.
Search is also where a purpose-built MCP CRM and a general-purpose workspace quietly diverge. If the term is new, start with what an MCP CRM is. Here is how it actually works in Historis, and why each choice matters when the caller is an agent rather than a human clicking around.
Full-text, not title-matching
The search an agent gets from a general-purpose workspace is typically title-oriented and documented as best-effort: it is allowed to miss matches. Historis search is Postgres full-text search over the actual content of every event and every contact.
Each event carries a search_vector, a generated, always-stored tsvector computed straight from the event's own free text, both the content and the close-out resolution note:
search_vector tsvector
generated always as (
to_tsvector('fr_unaccent',
coalesce(content, '') || ' ' || coalesce(resolution, ''))
) stored
Behind the event search vector sits a GIN index, an index type built for contains-style matching. Queries parse raw user input with websearch_to_tsquery, which never throws on stray punctuation or operators and takes the same accent-insensitive config the vector was built with:
query.textSearch('search_vector', userQuery, {
type: 'websearch',
config: 'fr_unaccent',
})
This accent-insensitivity comes from a custom fr_unaccent text-search configuration (Postgres unaccent + french_stem), and it is shared by both surfaces: a search for francois finds François, and crepiere finds crêpière, whether you are searching events or contacts. For contacts the vector goes wider still, covering every descriptive field: name, notes, email, phone, the full address.
Is a note searchable the instant you write it?
Yes. Because search_vector is a GENERATED ALWAYS STORED column, Postgres recomputes and persists it inside the same transaction as every insert and update. There is no background indexer, no refresh job, no eventual-consistency window.
A note your AI logs at 10:00:00 is searchable at 10:00:01. When a general-purpose tool's search rides an asynchronous index, a record created seconds ago can simply be missing, and that is exactly the gap that pushes an agent to create a duplicate because it could not find the original. Historis structurally cannot have that gap.
Who is allowed to see what, and when is that decided?
Before a single row is matched, the server resolves which records the caller is allowed to see, and scopes the query to exactly that set:
const visible = await getVisibleEventIds(userId, organizationId)
query.in('id', visible)
For contacts, the visibility check lives inside the search_contacts database function. Those visibility primitives are SECURITY DEFINER and callable only by the service role. They trust the user id and organization id carried by the verified OAuth token, never a client-supplied parameter. A caller cannot widen their own scope, and there is no IDOR, no way to request another tenant's record by guessing its id.
And "you are not allowed to see it" is returned identically to "it does not exist." Search can never be used as an oracle to probe for the existence of records hidden from the caller.
Keyset pagination, not OFFSET
Historis pages results with a keyset cursor over (occurred_at, id) rather than a numeric OFFSET. The server fetches limit + 1 rows; the extra row both proves a next page exists and supplies the cursor for it, in one round-trip. A keyset seek costs the same on page 1 as on page 100, because the database jumps straight to the cursor position in the index and never re-scans the rows before it. And it stays correct under concurrent writes: a row inserted or deleted elsewhere never shifts the page boundaries.
The cursor is an opaque token, and the server shape-checks its timestamp and UUID against strict patterns before either value is interpolated into a query. A hand-edited cursor cannot smuggle anything into the filter.
One detail is easy to miss. person_name is resolved entirely at the database. The server finds the matching contacts, then the events linked to them, and narrows the query by those ids before the page is fetched. This name filter composes with full-text search and with the keyset cursor, and it holds across every page, including pages the agent has not requested yet.
Can search follow relationships?
Contacts are a graph, so search_persons can filter by relationship. with_role and without_role answer questions like "find every supplier" or "clients who aren't tagged VIP yet". Matching runs against the label of a link or a tag on the linked contact, with the same accent-insensitive full-text configuration, all inside the visibility-scoped database function. This is the line a spreadsheet cannot cross, because flat rows have no relations to search across.
Built for an agent to read
Every Historis result comes back as compact structured content plus a short text line with ids and dates in the clear, not a wall of nested JSON the model has to wade through. Long bodies are previewed to ~280 characters; the linked people and tags ride along in the same payload, so the agent rarely needs a second call to make sense of a row.
Anything an AI wrote is marked with a ◆, so a result never lets a stored note impersonate an instruction, and you can always tell your own words from an assistant's.
Why it adds up
Put together, search over the Historis MCP is:
- Full-text: it matches what a record says, not just its title.
- Accent-insensitive, so crepiere finds crêpière, on events and contacts alike.
- Visibility-scoped before matching starts; a hidden record is never even a candidate.
- Instantly consistent: a note is searchable in the same transaction that wrote it.
- Keyset-paginated, with page 100 costing what page 1 costs, even under concurrent writes.
- Relationship-aware, filtering on the graph a spreadsheet does not have.
For your AI that means fewer round-trips, deterministic results, and a search surface that cannot leak across tenants or invent a match.
It is the same idea behind everything in Historis: your AI does the work, but the system keeps it correct, scoped, and traceable. Search also feeds the next step. An agent that can reliably find a prior interaction is one that can drive follow-ups without losing the thread. The tool contracts behind every query are in the MCP docs.
Related: how a multi-tenant agent surface stays leak-proof, the visibility scope every search runs inside.
Frequently asked questions
- How does search work over an MCP CRM for an AI agent?
- The agent calls a search tool over MCP and the server runs a Postgres full-text query over the real content of events and contacts. No model does the searching: an accent-insensitive tsvector is matched against the parsed query, scoped to the records the caller may see, then returned as compact structured content the agent can read in one pass.
- Does Historis use AI to power its search?
- No. Historis runs no model server-side. Search is deterministic Postgres full-text search over a stored tsvector. Your own AI, connected over MCP, decides what to search for and interprets the results, but the matching itself is plain database search, so results are repeatable rather than best-effort.
- Can an AI agent see records it is not allowed to?
- No. Visibility is resolved before any row is matched: the server scopes the query to exactly the records the caller may see, using SECURITY DEFINER functions that trust the verified OAuth token rather than a client-supplied parameter. "Not allowed to see it" is returned identically to "it does not exist," so search can never be used to probe for hidden records.
- Is a note the AI just wrote immediately searchable?
- Yes. The search vector is a GENERATED ALWAYS STORED column, so Postgres recomputes it in the same transaction as every insert and update. There is no background indexer and no eventual-consistency window, so a note logged at 10:00:00 is searchable at 10:00:01 and an agent will not create a duplicate because it could not find the original.
- Why can a CRM search relationships when a spreadsheet cannot?
- Contacts in Historis are a graph, so search can filter by relationship: find every supplier, or clients not yet tagged VIP, matched against the label of a link or a tag on the linked contact. A spreadsheet stores flat rows with no relations, so it cannot answer those questions without you rebuilding the joins by hand each time.
- How are AI-written entries distinguished from mine in search results?
- Anything an AI wrote is marked with a diamond glyph, attributed and logged, so a stored note can never impersonate an instruction and you can always tell your own words from an assistant's. AI writes apply directly but stay traceable and reversible, and connected-client permissions are chosen per organization at the consent screen, read-only by default.