# 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](../search.md).

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

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

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

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

```
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.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](../endpoints.md) — the whole surface, prerequisites, and how the verbs chain.
- [Search (fast lane)](../search.md) — `POST /search`, the plain-language lane with the blended pass and `escalate_to_chat`.
- [Lookalike](lookalike.md) — `similar_themes` is the theme-centroid sibling of the semantic lane.
- [Chat](../chat.md) — when "find" becomes "explain", hand the thread to the agentic lane.
