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 plain-language fast lane (POST /search, search.run) — which also does the blended web + news pass and the escalate_to_chat handoff — has its own guide.
| Operation | REST | MCP | Scope | |
|---|---|---|---|---|
| The routed verb | search.query | POST /search/query | search tool, action query | data:read |
| The field catalog | search.fields | GET /search/fields | search tool, action fields | data:read |
The four modes
mode | What runs | Reach 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. |
filter | Typed {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. |
fuzzy | The NL→SQL writer (the same engine behind POST /search, internal mode). | Your ask is plain language and countable ("open deals with no activity in 21 days"). |
semantic | Your query embedded and ranked against the conversation corpus by meaning. | The thing you want is a concept no column encodes ("sounds like pricing pushback"). |
Request shape
| Field | Type | Default | Notes |
|---|---|---|---|
query | string (≤2000) | — | Free-text ask, for the fuzzy and semantic lanes. |
mode | auto | filter | fuzzy | semantic | auto | Lane override. |
surface | interactions | deals | deal_qualification | interactions | Which warehouse surface the filter lane slices. |
filters | array (max 32) | [] | {field, op, value} predicates, ANDed together. |
order_by | {field, dir} | — | A vocabulary field, or a metric alias in aggregate mode. |
limit | int | 100 | Row / match cap, max 1000. |
group_by | array (max 8) | — | Filter lane: group fields; requires at least one metric. |
metrics | array (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) and every malformed field come back as a typed invalid_argument naming the problem — the error message is the documentation.
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:
curl "https://app.amdahl.co/api/platform/v1/search/fields" \
-H "X-API-Key: $AMDAHL_KEY"Over MCP, the search tool's fields action:
{ "action": "fields" }What comes back — one entry per surface (interactions, deals, deal_qualification):
{
"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"] }
]
}
]
}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.
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:
curl -X POST "https://app.amdahl.co/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:
{
"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 (the tenant filter and any data-scope predicate are injected by the query gate before it runs):
{
"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 }
}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:
{
"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" }
}{
"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 — the NL→SQL writer behind the fast lane, minus the blended pass:
{ "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. The response's detail field carries the full fast-search envelope (groups, coverage, message) when you want more than the rows.
The semantic lane
Meaning over the call corpus — for the asks where literal filters fail (nobody's CRM has an objection_flavor column):
{
"query": "What do customers say about onboarding friction and time-to-value?",
"mode": "semantic",
"limit": 20
}{
"success": true,
"mode_ran": "semantic",
"results": [
{
"id": "e2b1…",
"source_row_id": "utt_48213",
"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" },
"timing": { "total_ms": 640, "embed_ms": 210 }
}Read two fields before you use the results:
mode_ran— which lane actually executed. If an auto-routed ask landed onfuzzy, you got SQL-derived rows (andcompiled.sql), not similarity matches. Branch on this, don't assume.freshness.source— where the matches came from.pgvector= the fast vector mirror, withsynced_attelling 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 (cluster_id,label,score) rather than utterance-level rows. 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.
Scoping a semantic query
Semantic mode accepts a narrow filter set — enough to scope the similarity search without breaking it:
| Field | Operators |
|---|---|
company_id | eq, in |
occurred_at | gte, gt, lte, lt, between |
speaker_type | eq |
"Customer-voice pricing pushback at these three accounts, this quarter":
{
"query": "pushback and hesitation about pricing or contract terms",
"mode": "semantic",
"filters": [
{ "field": "company_id", "op": "in", "value": ["1852…", "9b03…", "77aa…"] },
{ "field": "occurred_at", "op": "gte", "value": "2026-04-01" },
{ "field": "speaker_type", "op": "eq", "value": "external" }
],
"limit": 25
}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.)
Prompts to hand an agent
Structured slice, discovery-first:
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:
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, scoped to external speakers since April. Report which lane ran
(mode_ran) and the freshness stamp, and quote the strongest 3 matches
verbatim with their similarity scores.Tips
- Read
search.fieldsonce, then trust the errors. A wrong field or operator returnsinvalid_argumentnaming the allowed set — the API teaches its own vocabulary. - Prefer the resolved stage facts. On
deals, filter and group ondeal_stage_status/deal_stage_label, not the rawdeal_stageid (tenant CRMs carry opaque stage ids). - Branch on
mode_ran, not on hope. Integrations that need similarity matches should forcemode: "semantic"and treatmode_ranas the confirmation. - Similarity scores are ordinal, not calibrated. Rank within one response; don't compare absolute values across queries.
- Scope to
speaker_type: "external"for customer voice — otherwise your own reps' phrasing ranks alongside the customers'.
See also
- Endpoints overview — the whole surface, prerequisites, and how the verbs chain.
- Search (fast lane) —
POST /search, the plain-language lane with the blended pass andescalate_to_chat. - Lookalike —
similar_themesis the theme-centroid sibling of the semantic lane. - Chat — when "find" becomes "explain", hand the thread to the agentic lane.