---
title: "Routed Warehouse Search"
method: POST
path: "/api/platform/v1/search/query"
tags: ["data"]
---

# Routed Warehouse Search

`POST /api/platform/v1/search/query`

## Request

- Base URL: `https://docs.amdahl.ai`
- URL: `https://docs.amdahl.ai/api/platform/v1/search/query`
- Auth: one of:
  - API key in header `X-API-Key`
  - HTTP bearer

One routed door over tenant warehouse data: each ask lands on typed filters, an NL-to-SQL pass, or vector similarity. Use when you hold a plain question OR typed predicates and want the cheapest lane picked automatically (force one via mode). Returns the lane, rows, any compiled SQL, and retry_guidance naming the exact params to re-send when part of a multi-part ask went unanswered. Act on retry_guidance instead of giving up. Lanes read different stores at different grains — read corpus.

## Request body

- object
  - `query` string: Free-text ask. Meaning-shaped wording ("what do customers say about…") routes to the semantic lane; other questions route to the NL-to-SQL fuzzy lane.
  - `max_subqueries` integer: How many independent sub-questions a multi-part ask may fan out into. Default 3; up to 5 synchronously and 12 with async:true. RAISE THIS when a reply comes back with retry_guidance.action = "raise_max_subqueries" — it means parts of your question were cut by this cap, and retry_guidance.suggested_params carries the value to use.
  - `async` boolean: Run as a background job instead of blocking. Returns {job:{job_id,status}} straight away; collect it by calling this verb again with that job_id and no query. USE THIS when a reply comes back with retry_guidance.action = "run_async" — the synchronous path is bounded at ~15s and your ask needs longer, and the async path also allows a higher max_subqueries. Fuzzy lane only; others name it in corpus.ignored_params.
  - `job_id` string: Collect a job started with async:true. Send this ALONE (no query). While status is "queued"/"running", poll again; on "complete" the full envelope is on detail.
  - `mode` 'auto' | 'filter' | 'fuzzy' | 'semantic': Lane override. Default auto: filters-with-no-query → filter; meaning-shaped query → semantic; else fuzzy. Lanes read DIFFERENT stores at different grains — read `corpus` on the response to see which one answered.
  - `surface` 'interactions' | 'deals' | 'deal_qualification': Warehouse surface for the filter lane. Default interactions. Ignored by the fuzzy and semantic lanes, which report it back in corpus.ignored_params when you pass it.
  - `audience` 'all' | 'customer_voice': Who counts as the voice you want. Default "all". Use "customer_voice" to keep only genuine customer / prospect speech and drop investor, advisor, media, partner, vendor and competitor chatter that merely shares an account record — while STILL keeping a real buyer on such an account (someone on a live opportunity, or a scored champion / economic buyer). This applies the platform's own canonical rule server-side; it is a disjunction you cannot express with `filters`, which are ANDed. interactions only. Applied on the filter lane, and on the semantic lane with hydrate (turned on for you unless you send hydrate:false). A request that would land on the fuzzy lane, which writes its own SQL, is rerouted to semantic with hydrate and says so in rerouted_from and notes. Refused on the lexical lane, which matches exact terms.
  - `hydrate` boolean: Semantic lane only. Semantic matches carry a truncated preview and no labels, so they cannot be quoted or attributed. Set true to bridge each match back to its full warehouse row — full `content` plus company, speaker title, pushback and buyer-signal columns. One row per match, containing the matched utterance, so a hit is citable. SPEAKER comes from the ATOMIC utterance that matched, not from the turn stamp: read speaker_attribution on each row. "utterance" = verified from the atomic source. "ambiguous" = two matched utterances share this row and disagree on speaker, so the speaker fields are NULL and nobody is named — check turn_speakers for who is in play. "turn" = the atomic source had no row, so the fields are the turn's FIRST member and are NOT verified. Separately, speaker_ambiguous says the row's TEXT spans more than one speaker (13.58% of turns on the tenant measured 2026-08-15) — a row can be cleanly attributed AND span two voices, so quoting the whole content as one person is still wrong there. Both fields ship on every turn-grain row including the clean ones; corpus.speaker_ambiguous_rows / corpus.speaker_unattributable_rows are the rates. speaker_ambiguous_source says where that verdict came from: "stamped" = read from the column the pipeline writes on the turn itself, "overlay" = reconstructed at read time because the pipeline recorded none for that turn. A missing record is NOT "one speaker" — permanently-excluded interactions never get one — so an unrecorded turn falls back rather than being reported unambiguous, and a "stamped" row also carries speaker_count. corpus.speaker_stamped_rows / corpus.speaker_fallback_rows are the split. Stakeholder columns (role_level, is_champion, is_economic_buyer, champion_score, inferred_persona) are derived from the turn stamp, so they are NULLED when the row is re-attributed to a different speaker rather than left describing someone else. Slower — two reads. Check corpus.hydrated_grain: "turn" is that row-per-match answer; "call" means the turn lookup failed and the rows are arbitrary turns from the right conversations (context, not the matches; also flagged corpus.degraded). Matches whose utterance belongs to no turn have no warehouse row and are counted in corpus.unresolved_matches; matches sharing one turn are corpus.collapsed_matches. Read those two directly rather than inferring a gap from a match_count/row_count delta — corpus.match_count is how many nearest neighbours the lane pulled and is BOUNDED BY limit, not a count of how many things genuinely matched. Caps at 200 distinct ids, reported as corpus.truncated.
  - `coverage` boolean: Opt into gap detection on corpus.coverage. Every response ALREADY carries corpus.coverage with max_timestamp / min_timestamp and an age histogram, computed free from the rows returned. This flag adds `gaps`: windows where the corpus HELD rows and this result set had none — the "your evidence has a hole in it" signal, as opposed to the ordinary quiet periods a plain empty-bucket scan would flag. Costs one extra bucketed read, so it is off by default. Read coverage.basis first: "matched_set" means the numbers describe your whole match set, "returned_rows" means they describe a truncated slice, and "unavailable" means coverage could not be measured — never read that as zero.
  - `filters` object[]: Typed predicates (ANDed — there is no OR; for the customer-voice disjunction use `audience`). Semantic mode supports only company_id / occurred_at / speaker_type, and cannot apply any of them on its theme-index fallback — check corpus.filters_applied. The filter lane supports every field the catalog advertises.
    - `field` string, required: A field from search_field://list.
    - `op` 'eq' | 'neq' | 'in' | 'not_in' | 'contains' | 'gt' | 'gte' | 'lt' | 'lte' | 'between' | 'is_null' | 'not_null', required
    - `value` unknown
  - `order_by` object: Filter lane: order by a vocabulary field or a metric alias (e.g. "count").
    - `field` string, required
    - `dir` 'asc' | 'desc', required
  - `limit` integer: Row / match cap; max 1000. A larger value is clamped to 1000 rather than refused, and the response reports it on limit_clamped and in notes. Default is lane-dependent: 100 on the filter and semantic lanes, 50 on the fuzzy lane (where an unforced plain-language question usually lands), so pass it explicitly when the row count matters.
  - `group_by` string[]: Filter lane: group fields (requires at least one metric).
  - `metrics` object[]: Filter lane: aggregations (sum/avg need a numeric field).
    - `fn` 'count' | 'count_distinct' | 'sum' | 'avg' | 'min' | 'max', required
    - `field` string: Required for every fn except count.

## Response `200`

OK

- object
  - `data` object, required
    - `success` boolean, required
    - `mode_ran` string
    - `results` unknown[]
      - unknown
    - `compiled` object
    - `freshness` object
    - `corpus` object
    - `timing` object
    - `cached` boolean
    - `detail` object
    - `job` object
    - `notes` string[]
    - `limit_clamped` object
    - `rerouted_from` string
    - `setup_needed` object: Present only when this workspace has no connected data source, so there is nothing to read. reason is no_sources_connected; message says so in plain words for the user; connect_url is the Connections page in the console. Give the user the message and the link. Absent when a source is connected.
      - `reason` 'no_sources_connected', required
      - `message` string, required
      - `connect_url` string, required
    - `error` object

## Other responses

- `400`: Invalid input - failed schema validation
- `401`: Authentication required
- `403`: Forbidden - missing scope or role
- `500`: Internal server error

---

[API](https://skmtc.dev/amdahl/apis/amdahl-platform-api.md) · [All operations](https://skmtc.dev/amdahl/apis/amdahl-platform-api/llms.txt) · [OpenAPI document](https://skmtc.dev/amdahl/apis/amdahl-platform-api/revisions/d4eee33e2472?raw)
