Search contacts
search_personssearch_persons finds contacts: deterministic full-text across every descriptive field of the record — name, notes, email, phone, address — combined with relationship-graph filters: restrict by kind, and by the role a linked contact holds (with_role / without_role). It never creates anything.
How matching works
The query runs as PostgreSQL full-text search over the contact's indexed descriptive fields. Matching is accent-insensitive ("francois" finds "François"), whole-word and stemmed, not substring. Role filters run inside the same visibility-scoped database function as the text match.
- The full-text index covers exactly the descriptive fields: name, notes, email, phone and address.
- Company registration and VAT numbers are not in the full-text index: read them from the record itself (get_person_context) rather than searching for them with query.
- Accent-insensitive both ways: a query typed without accents matches accented stored text, and an accented query matches unaccented text.
- A role matches the label of a relationship (read in either direction) OR a tag carried by the linked contact: with_role=supplier finds contacts linked to someone labelled supplier or tagged supplier.
- without_role is a server-side anti-join: it keeps only contacts with NO link matching the role, evaluated over the whole contact base — not a client-side filter over a first page of results.
- Visibility applies to the contacts returned AND to both endpoints of every relationship considered: a link to a contact you cannot see neither matches a role nor appears in links.
- without_tags takes comma-separated slugs and is a server-side anti-join like without_role: it keeps only contacts carrying NONE of them, evaluated over the whole contact base — not a client-side filter over a first page of results.
- With a query, exact names rank first, then matches in the name, then matches in notes and other fields. Each group sorts by display name then contact id. Without a query the order is alphabetical. limit defaults to 20 and accepts 1-50 per page (integers only); invalid values are rejected.
- Pass next_cursor back as cursor with the same filters to fetch the next page (limit may change). It is null on the last page. Ranked cursors are bound to the filters and caller. Previously issued alphabetical cursors retain their order. Editing a contact can change its rank or name and move it across pages.
Parameters
Everything the tool accepts, straight from the live inputSchema. All parameters are optional; a key the schema does not declare is rejected, never ignored.
| Parameter | Type | Description |
|---|---|---|
| query | string (<= 200) | Full-text search across the contact's name, notes, email, phone and address. Accent-insensitive, whole-word and stemmed, not substring. |
| kind | "individual" | "company" | Restrict to individuals or companies. |
| with_role | string (<= 100) | Only contacts that have a linked contact in this role — matched on the relationship label (either direction) or on a tag of the linked contact. |
| without_role | string (<= 100) | Only contacts with NO linked contact in this role. Example: kind=company with without_role=sales lists companies without a salesperson. |
| tags | string (<= 200) | Comma-separated inclusion slugs. Matches ANY tag by default, or ALL with match_all_tags=true. Composes with without_tags, query, kind and role filters before pagination. Every entry must be a valid slug: an unusable entry (or blank text) is an error, never silently dropped -- under ALL a dropped entry would narrow the request. A slug both in tags and without_tags is an error when nothing could match: under ALL, or when every included slug is excluded (a partial overlap under ANY simply cannot contribute). Omit tags for no inclusion filter. |
| match_all_tags | boolean | When true, require every tag supplied in tags instead of any one of them. Ignored when tags is omitted. An unknown tag makes ALL return no contacts. |
| without_tags | string (<= 200) | Comma-separated tag slugs. Keeps only contacts carrying NONE of them — a server-side anti-join over the whole contact base, like without_role. Every entry must be a valid slug: an unusable entry is an error, never dropped (dropping it would let the contacts it named through), and a value holding no valid slug is an error, never an unfiltered result. Example: without_tags="client,fournisseur,prospect" for contacts nobody has qualified yet. |
| limit | integer (1-50, default 20) | Maximum results per page. Defaults to 20; out-of-range or non-integer values are rejected. |
| cursor | string | Opaque pagination cursor from a previous response's next_cursor. Omit it for the first page. |
Response
Each contact comes back compact: identity, phone and email, a notes preview, tags, and its relationship links with their direction-aware labels. Fetch the full record with get_person_context.
{
"contacts": [
{
"id": "b2ce0d4f-7e19-4c2b-8f3a-5d6e7f8a9b0c",
"display_name": "François Martin",
"kind": "individual",
"phone": "+33 6 12 34 56 78",
"email": "francois@boulangerie-martin.fr",
"notes": "Prefers morning deliveries - ring the back door...",
"tags": [
"supplier"
],
"links": [
{
"id": "0a1b2c3d-4e5f-4789-8bcd-ef0123456789",
"name": "Boulangerie Martin",
"kind": "company",
"relationship": "manager",
"note": null
}
]
}
],
"next_cursor": null
}{
"contacts": [],
"next_cursor": null
}{
"type": "object",
"properties": {
"contacts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"display_name": {
"type": "string"
},
"kind": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"phone": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"email": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"notes": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"links": {
"type": "array",
"items": {
"type": "object",
"properties": {
"relationship": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"note": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": {}
}
}
},
"required": [
"id",
"display_name",
"kind",
"phone",
"email",
"notes",
"tags",
"links"
],
"additionalProperties": false
}
},
"next_cursor": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"contacts",
"next_cursor"
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false
}Usage examples
Find a contact from a descriptive detail
{
"query": "morning deliveries"
}{
"contacts": [
{
"id": "b2ce0d4f-...",
"display_name": "François Martin",
"kind": "individual",
"notes": "Prefers morning deliveries - ring the back door...",
"tags": [
"supplier"
]
}
],
"next_cursor": null
}Search despite a missing accent
{
"query": "francois"
}{
"contacts": [
{
"id": "b2ce0d4f-...",
"display_name": "François Martin",
"kind": "individual",
"email": "francois@boulangerie-martin.fr",
"tags": [
"supplier"
]
}
],
"next_cursor": null
}Companies having a contact in a role
{
"kind": "company",
"with_role": "sales"
}{
"contacts": [
{
"id": "7d8e9f0a-...",
"display_name": "Atelier Lemaire",
"kind": "company",
"tags": [
"client"
],
"links": [
{
"id": "3c4d5e6f-...",
"name": "Claire Lemaire",
"kind": "individual",
"relationship": "sales",
"note": null
}
]
}
],
"next_cursor": null
}Companies missing a role
{
"kind": "company",
"without_role": "sales"
}{
"contacts": [
{
"id": "9e0f1a2b-...",
"display_name": "Garage Petit",
"kind": "company",
"tags": [
"prospect"
],
"links": []
}
],
"next_cursor": null
}A role can come from a label or a tag
{
"with_role": "supplier"
}{
"contacts": [
{
"id": "b2ce0d4f-...",
"display_name": "François Martin",
"kind": "individual",
"tags": [],
"links": [
{
"id": "0a1b2c3d-...",
"name": "Boulangerie Martin",
"kind": "company",
"relationship": "supplier",
"note": null
}
]
},
{
"id": "5f6a7b8c-...",
"display_name": "Vergers Rousseau",
"kind": "company",
"tags": [
"supplier"
],
"links": []
}
],
"next_cursor": null
}Contacts nobody has tagged, a page at a time
{
"without_tags": "client,fournisseur,prospect",
"limit": 2
}{
"contacts": [
{
"id": "8e9f0a1b-...",
"display_name": "Cave Dupuis",
"kind": "company",
"tags": [],
"links": []
},
{
"id": "4a5b6c7d-...",
"display_name": "Fleuriste Bernard",
"kind": "company",
"tags": [
"newsletter"
],
"links": []
}
],
"next_cursor": "NGE1YjZjN2QtLi4ufEZsZXVy..."
}Guarantees and limits
- Both searches are deterministic PostgreSQL full-text search: same query, same data, same results. Nothing semantic, no vector matching — the query matches words, not meanings.
- Visibility is resolved before matching: the server first scopes the query to the records the caller may see, and only then looks for matches.
- A record you cannot see is indistinguishable from a record that does not exist: search never confirms the existence of hidden data.
- Indexes are recomputed in the same transaction as every write: an entry is searchable the instant it is created, with no background indexer and no consistency window.
- Historis runs no AI model to find results. Your agent decides what to search for and ranks or interprets what comes back; the matching itself is plain database search.
Two searches, two jobs
- search_events
- Searches the timeline: what happened, what is due, what changed. Composes full-text with business and time filters, keyset-paginated.
- search_persons
- Searches the contact records and the relationship graph: who someone is, how they are connected, which role links them.