Docs

Search

The routed search.query endpoint - typed filters, NL-to-SQL, and semantic similarity behind one door, with the search.fields vocabulary catalog

POST /search/query is one routed call over your warehouse. Every ask lands on one of four lanes (typed filter predicates, the fuzzy NL→SQL writer, semantic vector similarity, or lexical exact-term matching), picked by an auto-router unless you force a mode. The response always tells you which lane actually ran (mode_ran), the rows or matches, and any compiled SQL, so the call is a receipt as well as an answer.

This page covers search.query, its field catalog search.fields, and the orientation read search.overview: the whole synchronous search surface. For a question that needs several reads and one cited answer, send it to Research; for market data or a written deliverable, start a Chat.

OperationRESTMCPScope
The routed verbsearch.queryPOST /search/querysearch_customersdata:read
The field catalogsearch.fieldsGET /search/fieldslist_search_fieldsdata:read
The data overviewsearch.overviewGET /search/overviewresource search-overview://currentdata:read
POST/search/queryauth required

The routed verb. Every ask lands on one of four lanes; the response names the lane that actually ran.

GET/search/fieldsauth required

The filterable field catalog, derived from the same schema the filter compiler validates against - so it cannot drift.

GET/search/overviewauth required

Row counts and time spans for the three warehouse surfaces. The read to take before your first query: it tells you whether there is anything to search.

Check what is there first: search.overview

GET /search/overview answers "does this workspace have data, and how recent is it" before you compose a query. It needs only data:read and works on an empty workspace:

json
{
  "data": {
    "surfaces": [
      { "surface": "interactions", "row_count": 41822, "earliest_at": "2025-01-06T15:02:11.000Z", "latest_at": "2026-09-27T21:40:03.000Z", "time_field": "timestamp" },
      { "surface": "deals", "row_count": 612, "earliest_at": "2024-11-30T00:00:00.000Z", "latest_at": "2026-12-31T00:00:00.000Z", "time_field": "close_date" },
      { "surface": "deal_qualification", "row_count": 188, "earliest_at": "2026-06-02T08:15:40.000Z", "latest_at": "2026-09-28T06:01:12.000Z", "time_field": "computed_at" }
    ]
  }
}
  • row_count: 0 is a real zero: nothing has synced to that surface yet, and no query against it will return a row.
  • row_count: null means the read for that surface failed. It is not a zero. Retry before telling anyone the workspace is empty.
  • The span is measured on each surface's own time field (time_field), so deals.latest_at can sit in the future: it is the latest close date, not the latest sync.
  • Each surface is one warehouse read, so the overview spends up to three reads from the per-user warehouse-read budget. A surface refused by that budget reports null.

Over MCP it is the resource search-overview://current. No tool reads it, so a tools-only client should run a small filter-lane count with search_customers instead.

The modes

modeWhat runsReach for it when
auto (default)The router picks, in this order: filters or an aggregation with no free-text query → filter lane; a query containing a quoted phrase ("pricing", two characters or more between double quotes) → lexical; a filter-shaped ask → fuzzy; meaning-shaped wording ("what do customers say about…") → semantic; everything else → fuzzy.You want the cheapest correct lane chosen for you. Branch on mode_ran.
filterTyped {field, op, value} predicates compiled into one SELECT.You know exactly which fields and conditions you want, and the result must be deterministic — no model interpreting wording.
fuzzyThe NL→SQL writer: it plans your ask into sub-questions and writes SQL for each.Your ask is plain language and countable ("open deals with no activity in 21 days").
semanticYour query embedded and ranked against the conversation corpus by meaning.The thing you want is a concept no column encodes ("sounds like pricing pushback").
lexicalExact-term matching over the same utterance mirror the semantic lane reads, ranked by how rare each matched term is in your corpus. No embedding call.The exact words matter: a product name, a competitor, a phrase a buyer used.
Quoting a term changes the lane

Any query that contains a quoted phrase is routed to lexical under auto, so what do buyers say about "onboarding" returns lexical matches from the utterance mirror, not an NL→SQL answer or a meaning-ranked one. Read mode_ran and corpus.search_lexemes (the terms actually searched) before reading anything into the result. To force the other lanes, drop the quotes or set mode.

mode: "lexical" can be forced over MCP (the search_customers tool accepts it). Over REST the published mode enum does not include it yet, so "mode": "lexical" is refused with invalid_input; use a quoted phrase under auto to reach the lane.

Because the default limit differs per lane, pass limit explicitly whenever the row count matters — an auto-routed question that lands on fuzzy caps at 50, not 100.

The lanes do not slice one corpus

They read different stores, at different grains, holding different text. A result set from one is not interchangeable with a result set from another:

LaneStore it readsOne row is…Text you get
filterthe V2 warehouse — interactions, deals, or deal_qualificationone warehouse rowfull content on interactions; none on deals or deal_qualification
fuzzythe same warehouse, through SQL the lane writes (usually over interactions; corpus.surface names the surface it read), or the theme index for a themes askone warehouse row (one theme, from the index)full content when it read interactions; none on any other surface or on a theme
semantica pgvector mirror of interactions, bounded to speaker_type = 'external' at ingestone external utterancea truncated content_preview
semantic, degradedthe BigQuery theme indexone clustered themethe theme's own summaries
lexicalthe same pgvector mirror, through its full-text indexone external utterancethe first 400 characters (corpus.content: "preview"); a term past that point is not findable

Two consequences worth internalising. The semantic lane searches a strict subset of what the filter and fuzzy lanes search — the mirror holds customer-side speech only, so your own reps' phrasing is not in it to be found. And a semantic match carries a truncated preview plus four predicate hooks (company_id, occurred_at, speaker_type, and the ids) — not quotable text, and none of the label columns you would attribute a quote by, so pass hydrate: true when the match has to become a citation.

You never have to infer which of these answered. Every lane that actually reads a store returns a corpus block naming it, along with the grain, the row count, whether your filters really ran, and why an empty result is empty.

One known gap while we close it: the mirror the semantic lane reads is not currently filtered by your workspace's excluded-interaction settings, which the filter and fuzzy lanes do respect. So a semantic match can come from an interaction you have excluded, and the proportion is material on some workspaces. Treat semantic results as the wider set until this note goes away; if exclusions matter to your use case, confirm a match against the filter lane before acting on it.

Request shape

FieldTypeDefaultNotes
querystring (≤2000)—Free-text ask, for the fuzzy and semantic lanes.
modeauto | filter | fuzzy | semanticautoLane override. The lexical lane is reached through auto with a quoted phrase over REST, or forced with mode: "lexical" over MCP. See The modes.
max_subqueriesint3Fuzzy lane: how many sub-questions the ask may fan out into. Max 5 synchronous, 12 with async: true.
asyncbooleanfalseFuzzy lane: run as a background job and get a job handle instead of blocking. Another lane answers inline and names async in corpus.ignored_params.
job_iduuid—Collect a job started with async: true. Send it alone (no query).
surfaceinteractions | deals | deal_qualificationinteractionsWhich warehouse surface the filter lane slices. The fuzzy and semantic lanes ignore it and name it in corpus.ignored_params.
audienceall | customer_voiceallWhose voice counts. customer_voice splices the platform's own canonical customer-voice predicate server-side. interactions only. Turns hydrate on for the semantic lane; a request that would land on the fuzzy lane is rerouted to semantic with hydrate; refused on the lexical lane. See Audience.
hydratebooleanfalseSemantic lane only: bridge each match back to its full warehouse row — one row per match, containing the matched utterance, with quotable content and label columns. Ignored (and named in corpus.ignored_params) on the other lanes. See From match to citation.
coveragebooleanfalseAdd gap detection to corpus.coverage. The block itself is always there and always free; this buys the gaps array at the cost of one extra read. See How much your answer can speak for.
filtersarray (max 32)[]{field, op, value} predicates, ANDed together — there is no OR. For the customer-voice disjunction use audience.
order_by{field, dir}—A vocabulary field, or a metric alias in aggregate mode.
limitintlane-dependentRow / match cap, max 1000. A larger value is clamped to 1000, not refused, and the response reports it on limit_clamped. Default 100 on the filter and semantic lanes, 50 on the fuzzy lane (which is where an unforced plain-language question usually lands). On semantic, a fall back to the BigQuery theme index clamps the effective cap to 50.
group_byarray (max 8)—Filter lane: group fields; requires at least one metric.
metricsarray (max 8)—Filter lane: {fn, field} aggregations. fn ∈ count, count_distinct, sum, avg, min, max; every fn except count needs a field; sum / avg need a numeric one.

Operator → value shapes: a scalar for eq / neq / gt / gte / lt / lte / contains (case-insensitive substring), an array for in / not_in (max 200 values), a two-element [low, high] for between, and no value at all for is_null / not_null. Timestamps and dates take ISO strings.

An empty request (no query, no filters), a wrong type, an out-of-range value and an unknown filter field all come back as a typed error naming the problem. The one exception is a limit above 1000: it runs at 1000.

What the server adjusted: notes, limit_clamped, rerouted_from

When the server changes your request rather than refusing it, or cuts the result, the response says so in three top-level fields. Each is absent when it has nothing to report.

FieldNotes
notesPlain-language sentences, one per adjustment: a clamped limit, a reroute, hydrate turned on for audience, or a filter-lane result cut by its row limit. Read it before you read the rows.
limit_clamped{ "requested": 5000, "applied": 1000 } when your limit was above the maximum.
rerouted_fromThe lane the router picked when the request was moved off it. Today only fuzzy, for an audience: "customer_voice" request (see Audience). mode_ran is the lane that answered.
An unknown top-level key is dropped, not refused

The published schema declares additionalProperties: false, but the REST validator does not enforce it and the handler strips keys it does not know. A typo such as "filter": [...] for "filters": [...] is silently ignored; if the request also carries a query, the call runs unfiltered. Check compiled (and corpus.filters_applied on the semantic and lexical lanes) to confirm your filters ran. Inside a filter object, an unknown field is refused.

The corpus block

corpus is the provenance of the answer you are holding — which store answered, at what grain, over how many rows, and why a zero is a zero. It rides on every lane that actually reads a store:

FieldNotes
storewarehouse | pgvector | theme_index — which physical store answered.
grainrow (one warehouse row) | utterance (one mirrored external utterance) | theme (one cluster, not an utterance).
surfaceThe warehouse surface, when the answer came from one. On the fuzzy lane it is the surface the SQL that ran actually read, which is not always interactions (deals, competitor_mentions, …); absent when no SQL ran.
row_countRows actually returned to you.
match_countMatches before hydration. With hydrate: true, row_count ≤ match_count; see reconciling the counts for what the difference is.
hydrated_grainPresent only when hydrate ran. turn = one row per match, containing the matched sentence. call = the turn lookup failed and these are arbitrary turns from the right conversations; treat them as context, not as the matches.
unresolved_matchesMatches whose utterance belongs to no grouped turn, so no warehouse row contains them. A real absence, not a failure. Present whenever hydrate ran, including as 0.
collapsed_matchesMatches that resolved to a turn another match had already claimed — they share a row. Not missing, not substituted, and normally the largest part of the gap. Present whenever hydrate ran, including as 0.
truncatedtrue when a cap cut the result — either the 200-id bridge cap or your limit binding on the returned rows. On the filter lane it is measured, not guessed: see When the filter lane hits its limit.
total_countFilter lane only: every row your filters match, when a row read filled the 1000-row maximum and a count ran to settle whether anything was left. Absent otherwise; absent never means zero.
contentfull (quotable text plus label columns) or preview (the mirror's truncated content_preview). Absent when the rows have no content text to describe: every warehouse surface except interactions (deals, deal_qualification, and whatever else a fuzzy answer read) and the theme index, whose rows are clusters, not utterances.
speaker_scopeWhose speech the store that answered can hold: all (internal + external + unknown — the warehouse lanes and the theme index) or external_only (the pgvector mirror, bounded to customer-side speech at ingest). Present on every lane whose surface has a speaker dimension, all included; absent on every surface without a speaker column, which is every warehouse surface except interactions (deals, deal_qualification, …). Read it before reporting an internal-vs-external split.
filters_appliedWhether your filters reached the store. Always false on the fuzzy lane (which refuses filters outright) and when you sent none; the case to watch is the semantic lane's theme-index degrade, where you sent filters and the store has nowhere to put them.
ignored_paramsParams the schema accepted but the lane that ran does not honour — surface on fuzzy and semantic, hydrate anywhere but semantic.
audience_appliedWhether the customer_voice predicate was spliced in.
empty_reasonPopulated whenever the result conveys no matches: no_match | weak_matches_only (rows came back but none cleared the relevance bar) | scope_disjoint (your data scope admits nothing this read could return: your filter is disjoint from it, or your access holds no rows of the table it read) | not_synced (the mirror holds no rows for this workspace yet) | spelling_variants_unsearched (lexical lane: another spelling of your term could not be searched, so retrying returns the same result) | read_failed. On the fuzzy lane, a query that failed after its repair round reports read_failed and a writer that declined (the ask does not fit one SELECT, so nothing ran) reports unsupported; only a query that ran and matched nothing is no_match. Fires on bare aggregates too; see below.
min_relevanceThe relevance bar the lane applied, on the lexical and semantic lanes. The two measure different things under one name — lexical scores how distinguishing your matched term is, semantic scores vector distance — so read it beside mode_ran rather than comparing the numbers across lanes.
weak_matches_filteredHow many rows the bar removed. Present whenever a bar ran, 0 included: an absent field would make "nothing was cut" indistinguishable from a build that does not report it.
degradedtrue when the lane fell back off its primary store.
coverageHow much of the queried time range this answer can speak for. See How much your answer can speak for.

empty_reason: "read_failed" is the one to branch on. It means the underlying read broke, not that nothing matched — the rest are honest empties. Treat it as an error; retry rather than reporting a zero.

no_match and weak_matches_only are different answers. no_match is the only one licensing "your corpus holds nothing about this". weak_matches_only means rows came back and none of them cleared the bar — your corpus may hold plenty on the topic in words this query did not reach. The first says stop; the second says rephrase, or widen with hydrate.

A workspace with nothing connected: setup_needed

A workspace that has never connected a source has nothing to search, and its empty result looks the same as "nothing matched". So an empty answer from such a workspace also carries setup_needed, on the result beside results. A count over nothing is empty too: it comes back as one row of zeros ([{ "count": 0 }]), and that row carries the field as well.

json
{
  "data": {
    "success": true,
    "mode_ran": "fuzzy",
    "results": [],
    "setup_needed": {
      "reason": "no_sources_connected",
      "message": "This workspace has no data connected yet, so there is nothing to search. Connect your CRM or call recorder at https://console.amdahl.ai/acme/settings/connections, then ask again.",
      "connect_url": "https://console.amdahl.ai/acme/settings/connections"
    }
  }
}
FieldNotes
reasonAlways no_sources_connected.
messagePlain words for the person who asked. Show it as written.
connect_urlThe Connections page of this workspace in the console, where a member connects a source.

The call still answers 200 with success: true and every field it had before. setup_needed is not an error.

  • Show the user message and connect_url. Do not report "no results", and do not answer the question from general knowledge as if it came from their data.
  • It is absent in every other case: when the workspace has a connected source (also one that is broken, or one that was disconnected after it synced), when rows came back, and when the server could not check. An empty result without it is an honest empty: read corpus.empty_reason.
  • It goes away on the next call after the first source connects. The data itself appears once that source's first sync lands; until then an empty result carries no field.
  • Do not retry until a source is connected, and do not answer from your own knowledge. The full handling, with the 409 form research and evals use, is in Errors. Common questions are in the FAQ.
  • research.start, evals.run and messages.optimize with evidence: "workspace" carry the same field for the same reason. See Connections.

truncated and degraded are true-or-absent, never false — the same encoding as cached. An explicit false would assert that a cap was evaluated and not hit on lanes where no cap applies at all.

speaker_scope is the opposite encoding, deliberately: it is always stated. It used to appear only as external_only, which left all and "this build does not say" as the same absence — so a caller counting internal versus external speech off the warehouse lanes had nothing telling them the count was whole. Now every lane with a speaker dimension declares its coverage, all included, and absence means one thing only: this surface has no speaker dimension (any warehouse surface other than interactions, such as deals). It describes the store, never your query — a search you narrowed with speaker_type: "external" still reports all, because the question you asked cannot tell you what the corpus behind it holds. How much data your workspace actually holds is a different question again, answered by coverage.

content uses the same absence encoding. It says something about a column, so it is present only where the column is: full on interactions rows, preview on the pgvector mirror, and absent on deals, deal_qualification, every other surface and the theme index. Absent does not mean "unknown"; it means these rows hold no content text, so there is nothing to call full or truncated. It describes the surface, not your projection, so an interactions aggregate that selects no content still reports full. Test corpus.content === "preview" to detect a truncated read; do not treat a missing value as full.

json
{
  "store": "warehouse",
  "grain": "row",
  "surface": "interactions",
  "row_count": 42,
  "content": "full",
  "speaker_scope": "all",
  "filters_applied": true,
  "audience_applied": false
}

The async handshake carries no corpus — a handle is not a read. A completed job poll does: the rows are in hand, so it returns a corpus block (including coverage) alongside them. A poll that is still queued or running carries none. The full fast-search envelope is on detail.internal either way.

Why an empty aggregate says it is empty

A bare aggregate returns one row even over an empty population, so row_count alone cannot tell you the population was empty. empty_reason fires whenever the result conveys no matches: a count of 0, or a max of null, over nothing carries empty_reason: "no_match" alongside row_count: 1. Aggregates with a group_by return zero rows over an empty population, since no matching rows means no groups.

How much your answer can speak for

Every answer that reads a store carries corpus.coverage: the time range the returned evidence actually covers, and how old it is. It exists because a result set is equally confident and equally well-formed whether it read a complete corpus or one with a hole in it.

The lane where this bites hardest is semantic, and not hypothetically. It ranks by similarity, which carries no temporal guarantee at all — so a workspace whose newest call landed this morning can ask about pricing objections and get its three best matches from March, April and June. Nothing about that response looked stale.

FieldNotes
basismatched_set (these numbers describe your whole match set) | returned_rows (a cap cut the result, so they describe a slice) | unavailable (not measurable — never read this as zero).
basis_reasonPresent unless basis is matched_set: truncated | aggregate | no_time_field | no_rows.
max_timestampNewest event time among the covered rows, ISO-8601. null when basis is unavailable.
min_timestampOldest event time among the covered rows.
age_bucketsAll four bands, always, including zeros: last_7d, last_30d, last_90d, older.
dated_row_countHow many covered rows carried a readable event time — the histogram's denominator.
gaps_statusnot_requested (default) | ok | unavailable.
gapsPresent when gaps_status is ok. An empty array genuinely means none were found.
gaps_baselineWhich population the gaps were measured against: interactions, or interactions_external for the pgvector mirror.

Read basis before you read anything else. Coverage is not uniformly derivable: an aggregate carries no event-time column, a truncated result is an arbitrary slice of a larger set, and the fuzzy lane's SQL is written per call so its projection may contain no timestamp at all. Each of those reports basis: "unavailable" with a reason, and every number under it is nulled or zeroed as unmeasured, not as measured zero. Rendering an absent corpus size as 0 is how someone ends up debugging an integration that is fine.

The event-time field is read from a fixed allowlist — timestamp and occurred_at. Nothing else is guessed at, deliberately: a column-name heuristic would happily pick up close_date, which is a projected deal close rather than an event time, and report a confident wrong answer. On the warehouse the allowlist applies to interactions rows only: an answer read from any other surface (a fuzzy query over deals, say) reports basis: "unavailable" with no_time_field, whatever its columns are called, so coverage: true never reports gaps for it against your conversations.

What counts as a gap

A gap is a window where the corpus held rows and your result set had none — not simply a window your matches skip.

That distinction is the whole feature. Conversation volume is bursty, and empty days are business-normal: one healthy week in this corpus carried 276 calls with zero on Saturday, Sunday and Tuesday. A detector that flagged every empty bucket would fire constantly on a perfectly healthy workspace, and anything gating on it would learn to ignore it. Comparing against the corpus is what separates nothing happened here from something happened here and your query did not see it.

Two consequences worth knowing:

  • The window is yours when you name one. If your filters carry a range on timestamp or occurred_at, gaps are measured across that range. Otherwise they are measured across your matched set's own span — which cannot see an edge gap, because it never looks past its own last row. If you care whether your evidence runs out before the period you asked about, pass the range explicitly.
  • A failed baseline reads as unavailable, never as "no gaps". The extra read is best-effort and can never fail your search, so gaps_status: "unavailable" with gaps_reason: "baseline_failed" is distinct from gaps_status: "ok" with an empty gaps array.
json
"coverage": {
  "basis": "matched_set",
  "max_timestamp": "2026-08-10T17:22:04.000Z",
  "min_timestamp": "2026-03-26T19:30:00.000Z",
  "age_buckets": [
    { "bucket": "last_7d", "row_count": 0 },
    { "bucket": "last_30d", "row_count": 2 },
    { "bucket": "last_90d", "row_count": 5 },
    { "bucket": "older", "row_count": 11 }
  ],
  "dated_row_count": 18,
  "gaps_status": "ok",
  "gaps": [{ "start": "2026-08-11", "end": "2026-08-12", "corpus_row_count": 13 }],
  "gaps_baseline": "interactions"
}

That last_7d: 0 is the point. It is emitted rather than omitted, so "this answer surfaced no recent evidence" is something the envelope states instead of something you have to notice.

Audience: whose voice counts

audience answers a question the filter DSL structurally cannot. Filters are ANDed, and the platform's canonical customer-voice rule is a disjunction — so the segmentation the field catalog advertises was not expressible against it, and callers who wanted it re-derived it locally, each slightly differently. Stating the intent instead lets the server splice its own predicate, so your slice cannot drift from the one the page templates use.

audience: "customer_voice" keeps accounts the CRM types as customer or sales_target, plus accounts it has not typed at all; it drops investor, advisor, media, competitor, vendor, partner, reseller and other. It then rescues a genuine buyer sitting on a dropped account — someone on a live opportunity, or a scored champion or economic buyer — so a venture partner's advisory feedback is dropped while the buyer evaluating you from that same firm is kept.

LaneBehaviour
filterApplied directly. interactions only — asking for it on deals or deal_qualification is a typed refusal, because the columns it reads live on interactions alone.
semanticApplied on the hydrated warehouse leg, because the mirror carries none of the account-relationship or deal-attachment columns the predicate reads. If you leave hydrate out it is turned on for you and notes says so. Send hydrate: false and you get a refusal rather than a silently dropped scope.
fuzzyRerouted to semantic with hydrate: true. The fuzzy lane writes its own WHERE, so a spliced predicate would be overwritten. The response carries mode_ran: "semantic", rerouted_from: "fuzzy" and a line in notes. The rows are the customer utterances closest in meaning to your query, not a computed answer: for a count or a breakdown, send filters or metrics with the same audience so the filter lane runs. Refused instead if you sent hydrate: false.
lexicalRefused. You asked for exact terms, and the semantic lane that can apply the audience matches by meaning, so rerouting would answer a different question.

corpus.audience_applied confirms it actually ran. The default all is byte-identical to the pre-audience behaviour: this is a filter you opt into, not a corpus purge — top-of-funnel and no-deal voices stay in the warehouse for every query that wants them.

Step 0 — discover the vocabulary

Never guess at field names. The catalog lists every filterable field per surface — name, type, description, and the operators that field admits — and it is derived from the same schema the compiler validates against, so it cannot drift:

bash
curl "https://app.amdahl.ai/api/platform/v1/search/fields" \
  -H "X-API-Key: $AMDAHL_KEY"

Over MCP, the list_search_fields tool, with no arguments:

json
{}

The full catalog is large. To list one surface only, add surface (?surface=deals over REST, "surface": "deals" over MCP). Any other value is refused with the valid surfaces named (400 invalid_query_parameter over REST).

What comes back — one entry per surface (interactions, deals, deal_qualification), or only the one you asked for:

json
{
  "data": {
    "surfaces": [
      {
        "surface": "deals",
        "fields": [
          {
            "name": "deal_amount",
            "type": "FLOAT",
            "description": "The CRM's amount field ...",
            "operators": [
              "eq",
              "neq",
              "in",
              "not_in",
              "gt",
              "gte",
              "lt",
              "lte",
              "between",
              "is_null",
              "not_null"
            ]
          },
          {
            "name": "deal_stage_status",
            "type": "STRING",
            "description": "Funnel OUTCOME of the deal's stage: \"open\" | \"won\" | \"lost\" ...",
            "operators": ["eq", "neq", "in", "not_in", "contains", "is_null", "not_null"],
            "sample_values": ["open", "won", "lost"]
          }
        ]
      }
    ]
  }
}

A field advertised with an empty operators list (JSON / repeated columns) exists but is not filterable — you will see it in results, you just cannot predicate on it.

sample_values — read this before filtering on a coded field

Fields drawn from a fixed pipeline vocabulary carry sample_values: the spellings the pipeline can emit. Use it rather than guessing, because a filter on a value that does not exist is indistinguishable from one that exists and matches nothing — both return zero rows, and only one of them means what you will read it to mean. A caller building on this API invented four pushback_type names that were not in the vocabulary, got clean empty results for all four, and separately never queried build_vs_buy or incumbent, which between them carry thousands of utterances.

Two properties worth knowing:

  • A listed value is not a promise your workspace has rows carrying it. It promises the spelling is right and the pipeline can produce it. build_vs_buy is real vocabulary; whether your buyers raised it is what the query answers.
  • Absence of sample_values is deliberate, not an omission. Fields whose values come from your own CRM configuration (deal_stage_normalized, stage labels, ids, free text) carry none, because any list we published would be your tail rather than a closed set — and reading a partial list as complete is the exact failure above. For those, GROUP BY the column with a metrics query and see what you actually have.

The filter lane

The config-DSL lane: declarative predicates, compiled into one tenant-scoped SELECT that runs through the same access-checked query gate as everything else. The open pipeline, largest first:

bash
curl -X POST "https://app.amdahl.ai/api/platform/v1/search/query" \
  -H "X-API-Key: $AMDAHL_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "surface": "deals",
    "filters": [
      { "field": "deal_stage_status", "op": "eq", "value": "open" },
      { "field": "deal_amount", "op": "gte", "value": 50000 }
    ],
    "order_by": { "field": "deal_amount", "dir": "desc" },
    "limit": 25
  }'

Over MCP, the search_customers tool:

json
{
  "surface": "deals",
  "filters": [
    { "field": "deal_stage_status", "op": "eq", "value": "open" },
    { "field": "deal_amount", "op": "gte", "value": 50000 }
  ],
  "order_by": { "field": "deal_amount", "dir": "desc" },
  "limit": 25
}

The response carries which lane ran, the rows, and the compiled SQL as the receipt. It is the query as you asked for it, not the statement that reached the warehouse: the gate resolves the surface name to its fully-qualified warehouse table and injects the tenant filter and any data-scope predicate before it runs, so treat compiled.sql as a record of intent rather than something you can paste back verbatim.

json
{
  "data": {
    "success": true,
    "mode_ran": "filter",
    "results": [
      {
        "deal_id": "9214…",
        "deal_name": "Northwind expansion",
        "deal_amount": 180000,
        "deal_stage_label": "Contract Sent",
        "deal_stage_status": "open",
        "company_id": "1852…"
      }
    ],
    "compiled": {
      "sql": "SELECT `deal_id`, `deal_name`, `deal_amount`, … FROM deals WHERE `deal_stage_status` = 'open' AND `deal_amount` >= 50000 ORDER BY `deal_amount` DESC",
      "filters": [
        { "field": "deal_stage_status", "op": "eq", "value": "open" },
        { "field": "deal_amount", "op": "gte", "value": 50000 }
      ]
    },
    "timing": { "total_ms": 900 }
  }
}

A top-level cached rides on that envelope as true when the 5-minute query cache served the rows instead of a fresh warehouse run — the field that makes a suspiciously fast timing.total_ms attributable rather than a mystery. It is emitted on the filter and fuzzy lanes, and only on a hit: a miss omits the key rather than asserting false. The semantic lane never emits it at all, because that path embeds and searches live on every call — there is no result cache to hit, so a false there would imply one that missed.

Group + aggregate: the funnel in one call

group_by + metrics turns the slice into an aggregation. Metric aliases are count for {"fn":"count"} and <fn>_<field> otherwise (sum_deal_amount), and order_by can target an alias:

json
{
  "surface": "deals",
  "filters": [{ "field": "deal_stage_status", "op": "eq", "value": "open" }],
  "group_by": ["deal_stage_label"],
  "metrics": [{ "fn": "count" }, { "fn": "sum", "field": "deal_amount" }],
  "order_by": { "field": "sum_deal_amount", "dir": "desc" }
}
json
{
  "data": {
    "success": true,
    "mode_ran": "filter",
    "results": [
      { "deal_stage_label": "Contract Sent", "count": 14, "sum_deal_amount": 1240000 },
      { "deal_stage_label": "Discovery", "count": 32, "sum_deal_amount": 890000 }
    ],
    "compiled": {
      "sql": "SELECT `deal_stage_label`, COUNT(*) AS `count`, SUM(`deal_amount`) AS `sum_deal_amount` FROM deals WHERE …"
    }
  }
}

When the filter lane hits its limit

A row read that has more matches than its limit returns the first limit rows and says so: corpus.truncated: true and a line in notes. The lane measures this rather than inferring it from row_count == limit:

  • Below 1000, it asks the warehouse for one row past your limit and drops it before answering. If that row came back, more rows exist. A population that lands exactly on your limit is complete and carries no truncated.
  • At 1000, the warehouse maximum, there is no room for that extra row. When a row read fills it, the lane runs one COUNT(*) with the same filters and audience and reports the result as corpus.total_count, so notes reads like Results truncated at the 1000-row maximum: 1000 of 2341 matching rows returned. A population of exactly 1000 comes back without truncated. The count reads only the filter columns, so it costs no more than the read it follows, and it counts as one more read against your per-user warehouse-read budget. If the count cannot run, the response still says truncated: true and notes says more rows may exist.
  • An aggregate at 1000 is not counted (its rows are groups, not matches); notes says more groups may exist.
json
{
  "data": {
    "success": true,
    "mode_ran": "filter",
    "results": ["… 1000 rows …"],
    "corpus": { "row_count": 1000, "truncated": true, "total_count": 2341 },
    "notes": [
      "Results truncated at the 1000-row maximum: 1000 of 2341 matching rows returned. Narrow the filters, or use group_by + metrics to count or sum the whole population instead of reading rows."
    ]
  }
}

Rules the compiler enforces (each violation is a named invalid_argument): group_by requires at least one metric; sum / avg need a numeric field; in aggregate mode order_by must be a group field or a metric alias. Aggregate in the query, not over a capped row set — limit caps rows at 1000, and a metric computed client-side over a capped slice is a metric over an arbitrary subset.

The fuzzy lane

Plain language in, SQL out. This is the lane the retired POST /search ran, unchanged:

json
{ "query": "open deals with no activity in the last 21 days, biggest first", "mode": "fuzzy" }

The fuzzy lane takes a free-text query only — structured filters cannot ride along with it. Send both and land on fuzzy, and you get a typed refusal telling you to fold the constraint into the question or use the filter / semantic lanes.

Multi-intent asks split. "The objections we hit from 11x, and how are deals with Acme going?" is two independent questions. The lane plans the ask into the fewest sub-questions (3 by default), writes SQL for each, and runs them in parallel — so each gets a well-formed query instead of one conflated WHERE. The whole call still settles in seconds.

Splitting never rewords the question. A question that needs no split reaches the SQL writer word for word, and each part of a split may only reuse your own words. If a split would add a word you did not write, it is dropped: the whole question runs as one query, and detail.uncovered carries a planner_rewrote entry saying the answer may cover only part of it, with retry_guidance.action = "narrow_query" (ask the parts separately).

Name what you mean, with a capital. A question that names a company, person, product, segment, region or quarter ("What objections did Acme raise?", "pain points from Enterprise accounts", "objections in Q3") goes to the SQL writer, which filters on that name, so the answer is about it. The fast curated lists recognize a name by its capital letter, quotes, an email address or a domain, so write "Acme", not "acme". A capitalized word anywhere after the first is read as a name, so a question typed in Title Case skips the fast lists and takes the slower path.

A fast list answers only what it can apply. If a question carries something the list has no way to apply, it goes to the SQL writer, which applies it. That covers a topic ("pricing objections"), a segment ("enterprise"), a grouping ("by region", "per call"), a record type ("from emails", "in meetings"), "my" or "mine", a name in lower case, a second question ("and which accounts…"), an ordering the list does not do ("most recent champions"), or a time window it cannot read ("in 2024", "since January"). The fast lists read "last 30 days", "past 3 months", "last two weeks", "in the last month", "this quarter", "year to date", "today" and "yesterday" as a window running back from now or from the start of the period, and "last month", "last quarter", "last week" and "last year" as the previous calendar period. A question that names two windows, or one beside a word that reverses it ("other than last month"), goes to the SQL writer. The competitor landscape and win-rate lists have no per-call date, so a question that names a window for them goes to the SQL writer too. "Top 5" or "give me 10" caps the rows.

Breadth is a knob. max_subqueries raises the fan-out: up to 5 synchronously and up to 12 with "async": true. Values above the applicable cap are clamped, not rejected. Raise it when a reply comes back with retry_guidance.action = "raise_max_subqueries" — parts of your question were cut by the cap, and retry_guidance.suggested_params carries the value to send.

corpus describes the query that ran. Most fuzzy answers read interactions, but a deal question can be answered from deals, a competitor question from competitor_mentions, and a themes ask from the theme index. corpus.surface names the surface the SQL that ran actually read (that SQL is compiled.sql, or detail.internal.sql on a collected job). speaker_scope is omitted on a surface with no speaker column, and coverage reports basis: "unavailable" on any surface other than interactions. A themes answer reports store: "theme_index" and grain: "theme", with no surface.

The detail envelope. Fuzzy returns the full warehouse envelope on detail alongside the flat results / compiled.sql:

json
{
  "data": {
    "success": true,
    "mode_ran": "fuzzy",
    "results": [{ "objection": "pricing", "n": 42 }],
    "compiled": { "sql": "SELECT objection, COUNT(*) AS n FROM interactions WHERE …" },
    "detail": {
      "internal": {
        "status": "ok",
        "rows": [{ "objection": "pricing", "n": 42 }],
        "row_count": 1,
        "truncated": false,
        "cached": false,
        "repaired": false,
        "note": null
      },
      "groups": [
        {
          "label": "Objections",
          "question": "objection-tagged calls in the last 30 days",
          "internal": { "status": "ok", "row_count": 1 }
        }
      ],
      "message": "Found 1 row(s) in your workspace data — every row that matched (under your 50-row cap).",
      "coverage": {
        "latest_event_at": "2026-05-19T05:01:50Z",
        "total_rows": 67538,
        "days_behind": 1,
        "is_stale": false
      },
      "escalate_to_chat": false,
      "escalate_reason": null
    }
  }
}

Read these fields off it:

  • detail.internal.status — ok (rows came back) / empty (the ask is answerable, nothing matched) / unsupported (does not fit one query over one table, so no SQL was written) / failed (SQL ran and failed after one self-repair round). note explains every non-ok status in plain language; the raw database error is never surfaced.
  • detail.internal.truncated — true when more rows matched than your limit allowed, so what came back is a slice and not the whole answer. The lane asks the warehouse for one row past your cap and drops it before answering, so row_count never exceeds the limit you sent — the flag is the only place the overflow shows, and detail.message says the same thing in plain language. Any number you compute over a slice is real-but-wrong: raise limit, or fold the aggregation into the question itself ("how many, by stage") so the warehouse counts the whole population and hands back a few rows. Do not infer truncation from row_count == limit — below the engine cap, a population that lands exactly on your limit is complete and truncated is false for it. The one exception is limit: 1000, the engine's own maximum: there is no room to ask for a row past it, so a result that fills it always reads truncated: true. At that value the flag means "there may be more", not "there certainly is" — the only conservative reading in the field, and deliberately so.
  • detail.internal.cached — the fuzzy lane's copy of the cache flag it also hoists to the top-level cached (see the filter lane). Read the top-level one, so cache attribution looks the same whichever lane ran.
  • detail.groups — one entry per planned sub-question, each with its own label, question, and result. A single-intent ask yields one group.
  • detail.coverage — how much data you have and how current it is: total_rows (interaction rows the workspace holds at all), latest_event_at (the newest conversation), latest_ingest_at (the newest sync — the pair distinguishes "no calls happened" from "no sync ran"), days_behind, and is_stale. Render it as a freshness badge; when your data is materially behind "now" and a recent-window ask came back empty, message folds in a plain-language note so an empty result reads as a coverage gap rather than a failure. total_rows: 0 with latest_event_at: null is the tell for a workspace that has not finished its first sync — see Once your conversations are synced in the Quickstart.
  • detail.retry_guidance — the single most useful change to make, machine-readable on action: raise_max_subqueries (parts were cut by the breadth cap), run_async (parts ran out of time), narrow_query (the ask does not fit this lane), or none (full coverage). suggested_params carries the params to merge into the same query on the retry, and reason is one line you can show a human.
  • detail.escalate_to_chat / escalate_reason — true when the ask is out of this lane's reach and Chat is the better door: any sub-question came back unsupported/failed, or the planner spotted a non-data intent (advice, a recommendation, a "what should I say"). The data groups it could answer still come back either way.

The async lane

The synchronous fuzzy path is bounded at roughly 15 seconds. For a deliberately broad, multi-part ask, hand it off: pass "async": true and you get a handle back straight away instead of a blocked connection. The job runs against a three-minute budget rather than the caller's patience, so it also allows a higher max_subqueries — 12, versus 5 synchronously.

json
{
  "query": "objections by segment, and which of them close anyway",
  "mode": "fuzzy",
  "async": true,
  "max_subqueries": 8
}
json
{
  "data": {
    "success": true,
    "mode_ran": "fuzzy",
    "results": [],
    "timing": { "total_ms": 40 },
    "job": {
      "job_id": "3f1c…",
      "status": "queued",
      "poll": "Call search.query again with `job_id` (and no `query`) to collect the result."
    }
  }
}

Collect it by calling the same verb with the job_id alone — no query, no filters:

json
{ "job_id": "3f1c…" }

job.status walks queued → running → complete | failed. While it is queued or running, poll again; on complete the full fuzzy envelope is on detail and the rows are on results. A polled result does not re-emit compiled.sql — read the SQL off detail.internal.sql instead. Handles are kept for one hour; after that the poll returns a typed invalid_argument telling you to re-run the original query.

The semantic lane

Meaning over the call corpus — for the asks where literal filters fail (nobody's CRM has an objection_flavor column):

json
{
  "query": "What do customers say about onboarding friction and time-to-value?",
  "mode": "semantic",
  "limit": 20
}
json
{
  "data": {
    "success": true,
    "mode_ran": "semantic",
    "results": [
      {
        "id": "e2b1…",
        "source_row_id": "78dbcf40b5f2bdea3670e1519db9fd54",
        "interaction_id": "129889885",
        "parent_interaction_id": "129889885",
        "company_id": "1852…",
        "company_name": "Northwind Logistics",
        "occurred_at": "2026-06-11T15:20:00Z",
        "speaker_type": "external",
        "content_preview": "honestly the rollout took longer than the eval — six weeks before the team saw value…",
        "similarity": 0.83
      }
    ],
    "freshness": { "source": "pgvector", "synced_at": "2026-07-21T04:12:09Z" },
    "corpus": {
      "store": "pgvector",
      "grain": "utterance",
      "row_count": 20,
      "content": "preview",
      "speaker_scope": "external_only",
      "filters_applied": true
    },
    "timing": { "total_ms": 640, "embed_ms": 210 }
  }
}

Read these fields before you use the results:

  • mode_ran — which lane actually executed. If an auto-routed ask landed on fuzzy, you got SQL-derived rows (and compiled.sql), not similarity matches. Branch on this, don't assume.
  • freshness.source — where the matches came from. pgvector = the fast vector mirror, with synced_at telling you how recently it synced (a result cannot contain a call from after that stamp). bigquery = the lane transparently fell back to the warehouse theme index — the results are then theme-level matches (source_id, label, similarity, member_count, interestingness_score, plus description / insight / representative_quotes) rather than utterance-level rows, and the row cap is clamped to 50 on that path. The fallback is honest coverage, not an error: it fires when the fast mirror is not enabled or has not synced for your workspace yet.
  • corpus — the same story in machine-readable form, and the only place two of its cases are distinguishable. store: "theme_index" with degraded: true is the fallback above; on that path filters_applied is false, because the theme index takes a query and a limit and has nowhere to put a company_id or occurred_at predicate. And empty_reason: "read_failed" marks a read that broke rather than one that honestly matched nothing — the two used to be the same empty array.
  • corpus.weak_matches_filtered — how many neighbours were too distant to return. Nearest-neighbour search ranks; it does not judge. Ask about something your corpus does not cover and the store still returns its closest rows, which is why this lane applies a relevance bar and reports what it cut. A high count next to few results means the topic is thin in your data, not that the query failed.

Scoping a semantic query

Semantic mode accepts a narrow filter set — enough to scope the similarity search without breaking it:

FieldOperators
company_ideq, in
occurred_atgte, gt, lte, lt, between
speaker_typeeq

speaker_type is accepted here but has nothing left to do: the mirror is external-only by construction, so eq external narrows nothing and eq internal is an empty result rather than a narrower search. When what you want is buyer voice separated from the investor, advisor and partner speech that merely shares an account record, reach for audience instead.

"Customer-voice pricing pushback at these three accounts, this quarter":

json
{
  "query": "pushback and hesitation about pricing or contract terms",
  "mode": "semantic",
  "audience": "customer_voice",
  "hydrate": true,
  "filters": [
    { "field": "company_id", "op": "in", "value": ["1852…", "9b03…", "77aa…"] },
    { "field": "occurred_at", "op": "gte", "value": "2026-04-01" }
  ],
  "limit": 25
}

hydrate: true is what applies the audience there: it reads account-relationship and deal-attachment columns the mirror does not carry, so on this lane the predicate can only be applied on the hydrated warehouse leg. Leave hydrate out and it is turned on for you (notes says so); send hydrate: false and the request is refused.

Any other field or operator in semantic mode returns a typed invalid_argument naming the supported set. (Note the semantic lane's time field is occurred_at; the filter lane's interactions catalog calls the same event time timestamp.)

From match to citation

A semantic match is a good find and a poor citation. The mirror stores a truncated content_preview and four predicate hooks — no speaker_title, no pushback_type, none of the buyer-signal columns. There is not enough there to quote a customer or say who they were.

Semantic and lexical rows do name the account. Amdahl keeps a list of your workspace's account names and adds company_name beside each company_id from it, so naming adds no time to a search once the list is loaded. The name is the one the interactions surface shows: the CRM name, else the first part of the company's domain. The key has three states. A string is the account's name. null means the account has no name, or the row has no company. An absent key means the name is unknown: the list was still loading (the workspace's first search in a day can be one), or it does not hold that company yet. When any name is unknown, notes says how many rows, and how to look them up with a filter search. The search never fails because of it. Rows do not name a deal, because a company can have several.

hydrate: true closes that gap by bridging each match back to its warehouse row through the same gated read the filter lane uses, so you get full content and every label column in one call:

json
{
  "mode": "semantic",
  "query": "what worries customers about onboarding",
  "hydrate": true,
  "audience": "customer_voice",
  "limit": 25
}

One row per match, containing the matched utterance. The bridge resolves each match to the exact conversational turn it came from, so corpus.row_count is at most corpus.match_count and the two are worth comparing. Confirm which bridge ran on corpus.hydrated_grain:

hydrated_grainWhat you are holding
turnThe answer you want: one row per match, and that row contains the matched sentence.
callA fallback, flagged with degraded: true. The turn lookup failed, so these are arbitrary turns from the right conversations — usable as context, not as the matches.

Hydrating widens the corpus, and speaker_scope says so. The matches were found in the external-only mirror, but the rows you get back are full warehouse turns — so a hydrated response reports "speaker_scope": "all" where the un-hydrated one reported "external_only". Matching is still customer-side; the text you are now holding is not.

Reconciling the counts

row_count is normally lower than match_count, and that is expected rather than a sign anything was dropped. Two things account for it, both counted:

code
match_count = row_count + collapsed_matches + unresolved_matches
  • collapsed_matches — usually the bigger term. Several matched utterances routinely land in the same turn, so they share one row. Grouped turns hold 2.88 atomic utterances on average and 78.3% hold more than one, and semantic neighbours are especially likely to sit next to each other — so roughly a quarter of matches collapsing is normal. Nothing is missing; one row carries several of your matches.
  • unresolved_matches — usually small. A matched utterance never folded into a grouped turn has no row on interactions at all (1–12% of external utterances depending on the workspace). These are counted rather than back-filled with a neighbouring row, so a citation you build from a hydrated result is always the sentence that actually matched. If every match is unresolvable, the response says so and skips the warehouse read entirely.

Both fields are present whenever hydrate ran, including when they are 0 — so their absence means hydration did not run, never that nothing collapsed. The identity can still leave a remainder if the gated warehouse read itself withheld rows (an audience predicate, an excluded interaction, or your limit binding); truncated and audience_applied tell you when that happened.

What you trade for the labels. Hydrated rows are warehouse rows, so they carry no per-match similarity and do not come back in similarity order — when the ranking is what you care about, read the un-hydrated matches first and hydrate second. And on the theme-index degrade there is no id to bridge on at all, so hydration is skipped and hydrate shows up in corpus.ignored_params rather than failing the search. Beyond 200 distinct ids the list is capped and corpus.truncated says so.

If you would rather bridge yourself

Only one join resolves from the response alone:

To join a semantic match to…Use
its call on interactionsparent_interaction_id → parent_interaction_id ✅
the exact turn that matchedhydrate: true — the key is not on the response

Two joins look right and are not, and the reason is worth understanding once — it will stop you trying the third.

These ids are different grains of one lineage, not unrelated keys. Several atomic utterances are grouped into one conversational turn, so source_row_id identifies an atomic utterance while the filter lane's interaction_id identifies the grouped turn it belongs to. They are both 32-character hex because they are minted by the same hash function at different levels — which is exactly why they look interchangeable and are not.

So: do not join interaction_id to interaction_id (parent call on one side, grouped turn on the other), and do not reach for source_row_id because its shape matches (atomic, one level below the turn). Both return zero rows silently, which reads like "no data" rather than "wrong key".

The turn id that would join is resolvable, and hydrate: true is how you get it — that path returns the matched turn directly. We deliberately do not resolve it on every semantic call: it costs an extra warehouse read, and this lane exists to answer from the vector mirror alone in well under a second.

The lexical lane

Exact-term matching over the same customer-side utterance mirror the semantic lane reads, ranked by how rare each matched term is in your corpus. No embedding call is made. You reach it by putting a phrase in double quotes under mode: "auto", or over MCP by setting mode: "lexical"; the REST mode enum does not accept lexical today.

json
{ "query": "who asked about \"SSO\"", "limit": 10 }
json
{
  "data": {
    "success": true,
    "mode_ran": "lexical",
    "results": [
      {
        "source_row_id": "utt_8f21…",
        "parent_interaction_id": "c7d0…",
        "company_id": "4b19…",
        "company_name": "Contoso Health",
        "occurred_at": "2026-08-14T16:03:22.000Z",
        "speaker_type": "external",
        "content_preview": "we cannot roll this out to the whole org without SSO",
        "score": 7.42,
        "relevance": 1,
        "matched_lexemes": ["sso"]
      }
    ],
    "corpus": {
      "store": "pgvector",
      "grain": "utterance",
      "row_count": 1,
      "content": "preview",
      "speaker_scope": "external_only",
      "filters_applied": false,
      "search_lexemes": ["sso"],
      "min_relevance": 0.6,
      "weak_matches_filtered": 3
    }
  }
}
  • A quoted phrase scopes the search to what you quoted. The words around it do not compete with it.
  • corpus.search_lexemes is what was actually searched, after normalizing and after dropping terms too common to distinguish anything (listed in dropped_lexemes). Read it before you read a zero.
  • relevance (0 to 1) says how much of your query's distinguishing weight a row matched; score only ranks. Rows under corpus.min_relevance are removed and counted in weak_matches_filtered, and when every row was removed the answer says empty_reason: "weak_matches_only" rather than no_match.
  • It reads a 400-character preview. A term past the first 400 characters of a long utterance is not findable here. Use the filter lane's contains operator on content for long text.
  • Filters are the semantic subset: company_id, occurred_at and speaker_type. hydrate and surface are reported in ignored_params, and audience: "customer_voice" is refused.
  • parent_interaction_id is call grain. It joins to the warehouse's interactions.parent_interaction_id, not to the filter lane's turn-grain interaction_id.

Prompts to hand an agent

Structured slice, discovery-first:

code
Structured slice of our data — use the search_customers tool with typed
filters, not a plain-language search. First call list_search_fields with
surface deals and check the exact field names. Then run: open deals with amount
>= 50000, ordered by amount descending, top 25. Show me the filters you sent
AND the compiled SQL that came back, so I can reuse the exact slice later.

Meaning search:

code
Meaning search over our call corpus — use the search_customers tool in
semantic mode. Find what customers say about onboarding friction and
time-to-value since April. Report which lane ran (mode_ran), the freshness
stamp, and the corpus block (store, grain, content), then list the strongest
3 matches with their similarity scores. Now re-run the same query with
hydrate true to pull those matches back as full warehouse rows, and quote
from those — the previews are truncated.

Tips

  • Read search.fields once, then trust the errors. A wrong field or operator returns invalid_argument naming the allowed set — the API teaches its own vocabulary.
  • Prefer the resolved stage facts. On deals, filter and group on deal_stage_status / deal_stage_label, not the raw deal_stage id (tenant CRMs carry opaque stage ids).
  • A blank deal on interactions has two meanings — deal_match_method tells you which. deal_stage_status / deal_stage_label / deal_amount are all null both when the company genuinely has no deal (deal_match_method is null) and when the attach refused — the company has deals and none was attachable at the time of the conversation (deal_match_method: "no_open_deal"). That is ~61% of linked rows fleet-wide, so reading every blank as "no pipeline here" is the single easiest way to misread this surface. Roll the first kind up at the company; treat the second as unattributed. And exclude company_post_close from win/loss analysis — that conversation happened after the deal closed, so keeping it leaks the outcome you are trying to predict.
  • Branch on mode_ran, not on hope. Integrations that need similarity matches should force mode: "semantic" and treat mode_ran as the confirmation.
  • Similarity scores are ordinal, not calibrated. Rank within one response; don't compare absolute values across queries.
  • Don't reach for speaker_type to get customer voice on the semantic lane. The mirror is bounded to external speech at ingest, so speaker_type: "external" is a no-op there and "internal" returns a structural zero. On the filter lane it does separate reps from customers — but "customer voice" usually means excluding investors, advisors and partners too, and that is audience: "customer_voice".
  • Hydrate before you quote. A raw semantic match carries a truncated preview and no label columns — enough to rank, not enough to cite. hydrate: true gets you the full text and the attribution, at call grain.
  • Read corpus, not the shape of the rows. It names the store that answered, whether your filters actually ran, and — through empty_reason: "read_failed" — whether an empty result is an answer or a broken read.

See also

  • FAQ: why a search is empty, and what setup_needed means
  • Endpoints overview — the whole surface, prerequisites, and how the verbs chain.
  • Chat — when "find" becomes "explain", hand the thread to the agentic lane.