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

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

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 (the same engine behind POST /search, internal mode).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").

Request shape

FieldTypeDefaultNotes
querystring (≤2000)Free-text ask, for the fuzzy and semantic lanes.
modeauto | filter | fuzzy | semanticautoLane override.
surfaceinteractions | deals | deal_qualificationinteractionsWhich warehouse surface the filter lane slices.
filtersarray (max 32)[]{field, op, value} predicates, ANDed together.
order_by{field, dir}A vocabulary field, or a metric alias in aggregate mode.
limitint100Row / match cap, max 1000.
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.

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.co/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
{
  "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:

bash
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:

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 (the tenant filter and any data-scope predicate are injected by the query gate before it runs):

json
{
  "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:

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
{
  "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:

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. 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):

json
{
  "query": "What do customers say about onboarding friction and time-to-value?",
  "mode": "semantic",
  "limit": 20
}
json
{
  "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 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 (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:

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

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

json
{
  "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:

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, 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.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).
  • 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.
  • 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 and escalate_to_chat.
  • Lookalikesimilar_themes is the theme-centroid sibling of the semantic lane.
  • Chat — when "find" becomes "explain", hand the thread to the agentic lane.