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.
- Responses are capped at limit (at most 50) contacts sorted by display name, and there is no pagination cursor: if a search may exceed the cap, narrow it with query, kind or a role filter.
Parameters
Everything the tool accepts, straight from the live inputSchema. All parameters are optional.
| 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. |
| limit | number | Maximum results. Defaults to 20, capped at 50. |
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
}
]
}
]
}{
"contacts": []
}{
"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
}
}
},
"required": [
"contacts"
],
"$schema": "http://json-schema.org/draft-07/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"
]
}
]
}Search despite a missing accent
{
"query": "francois"
}{
"contacts": [
{
"id": "b2ce0d4f-...",
"display_name": "François Martin",
"kind": "individual",
"email": "francois@boulangerie-martin.fr",
"tags": [
"supplier"
]
}
]
}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
}
]
}
]
}Companies missing a role
{
"kind": "company",
"without_role": "sales"
}{
"contacts": [
{
"id": "9e0f1a2b-...",
"display_name": "Garage Petit",
"kind": "company",
"tags": [
"prospect"
],
"links": []
}
]
}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": []
}
]
}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.