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 door over your warehouse. Every ask lands on one of three lanes — typed filter predicates, the fuzzy NL→SQL writer, or semantic vector similarity — 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 and its companion catalog search.fields — the whole synchronous search surface. A separate POST /search fast lane used to sit beside it; it was retired. Its warehouse behaviour is the fuzzy lane below, verbatim and with the same response envelope, so a caller moves over by posting to /search/query with "mode": "fuzzy". Its web + news blended pass and its one-paragraph synthesis are gone from this surface — reach for external_search when you want market data, or Chat when you want prose.

OperationRESTMCPScope
The routed verbsearch.queryPOST /search/querysearch tool, action querydata:read
The field catalogsearch.fieldsGET /search/fieldssearch tool, action fieldsdata:read
POST/search/queryauth required

The routed verb. Every ask lands on one of three 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.

The four modes

modeWhat runsReach for it when
auto (default)The router picks: filters with no free-text query → filter lane; 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").

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
fuzzythe same warehouse, through SQL the lane writes over interactionsone warehouse rowfull content
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

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.
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.
job_iduuidCollect 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; needs hydrate: true on the semantic lane; refused on the fuzzy 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. 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. fncount, 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) and every malformed field come back as a typed invalid_argument naming the problem — the error message is the documentation.

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.
row_countRows actually returned to you.
match_countMatches before hydration. With hydrate: true, row_countmatch_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.
contentfull (quotable text plus label columns) or preview (the mirror's truncated content_preview).
speaker_scopeexternal_only when the store holds customer-side speech only — the pgvector mirror always does.
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 | scope_disjoint (your filter is disjoint from your data scope) | not_synced (the mirror holds no rows for this workspace yet) | read_failed. Now reaches aggregates too — see below.
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 other three are honest empties. Treat it as an error; retry rather than reporting a zero.

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.

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.

Aggregates can now say why they are empty

empty_reason used to key off row_count, and a bare aggregate returns one row containing a zero — so row_count was 1, and the field was unreachable on every aggregate ever run. A count over an empty window came back as {"count": 0} with nothing on the envelope explaining it.

It now fires when the result conveys no matches, which includes a bare aggregate whose population is empty. A count of 0, or a max of null, over nothing now carries empty_reason: "no_match" alongside row_count: 1. Aggregates with a group_by were always fine — no matching rows means no groups means zero rows.

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.

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 living docs and 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.
semanticRequires hydrate: true. The mirror carries none of the account-relationship or deal-attachment columns the predicate reads, so it can only be applied on the hydrated warehouse leg. Ask without hydration and you get a refusal rather than a silently dropped scope.
fuzzyRefused. That lane writes its own WHERE, so a spliced predicate would be overwritten.

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 search tool's fields action:

json
{ "action": "fields" }

What comes back — one entry per surface (interactions, deals, deal_qualification):

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:

json
{
  "action": "query",
  "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 …"
    }
  }
}

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.

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.

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.statusok (rows came back) / empty (the ask is answerable, nothing matched) / unsupported (does not fit a single SELECT, no SQL 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.truncatedtrue 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 the Quickstart prerequisites.
  • 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_reasontrue 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 queuedrunningcomplete | 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…",
        "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.

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 not optional there: audience 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, and asking without it is a typed refusal.

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 company_name, 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.

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
{
  "action": "query",
  "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.

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_idparent_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.

Prompts to hand an agent

Structured slice, discovery-first:

code
Structured slice of our data — use the search tool's query action with typed
filters, not a plain-language search. First call the fields action and check
the exact field names for the deals surface. 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 tool's query action 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

  • Endpoints overview — the whole surface, prerequisites, and how the verbs chain.
  • Chat — when "find" becomes "explain", hand the thread to the agentic lane.