# Search

> Already know the shape of the answer — typed filters, an aggregation, a meaning search? The routed [`POST /search/query` endpoint](endpoints/search.md) is the deterministic sibling of this lane.

Consumer-facing guide to `POST /search` — the **fast lane**: one blocking call that returns data now, no agent run. It is the low-latency counterpart to [Chat](chat.md). Where Chat opens a conversation and runs a multi-step Master agent turn server-side (always async, handles only), Search blocks for a single answer: it turns your ask into SQL over your `interactions` warehouse and runs it, and in `blended` mode adds a quick web + news citation pass.

## Search or Chat?

| Reach for **Search** (`POST /search`) when…                        | Reach for **Chat** (`POST /chat`) when…                                   |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| You want rows / a count back in one call, right now.               | The question needs several steps, judgement, or a written narrative.      |
| The ask is a single question over your interaction data.           | The ask spans surfaces, needs delegation, or long-form synthesis.         |
| A few web + news citations alongside the data is enough.           | You want the full market fan-out + the internal-vs-market divergence map. |
| You are wiring a dashboard cell, a slash command, an autocomplete. | You are running an investigation Amdahl should carry out end to end.      |

Search settles in seconds (a ~15s hard ceiling). Chat can take minutes. If an ask is too open-ended for the fast lane, Search says so — the response carries `escalate_to_chat: true` and a plain-language `message`.

## Call it

```bash
curl -X POST https://app.amdahl.co/api/platform/v1/search \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "objection-tagged calls in the last 30 days",
    "mode": "internal"
  }'
```

### Parameters

| Field            | Default    | What it does                                                                                                                     |
| ---------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `query`          | (required) | The ask, in plain language.                                                                                                      |
| `mode`           | `internal` | `internal` = warehouse only. `blended` = also fan out web + news for citations.                                                  |
| `limit`          | `50`       | Internal row cap. Max `1000`.                                                                                                    |
| `external_limit` | `10`       | Blended citation cap. Max `24` — the fast lane returns a handful of high-signal citations, not a research corpus.                |
| `synthesize`     | `false`    | Opt into a one-paragraph headline over the merged evidence. Off by default: the fast lane returns data, not prose, unless asked. |

Unknown fields are stripped; a bad `mode` / `limit` surfaces as one `invalid_argument` error rather than a silent clamp gone wrong.

### internal vs blended

- **`internal`** (default) plans your ask into one or more sub-questions, writes SQL over your `interactions` warehouse for each, and runs them **in parallel** through the same query gate the rest of the platform uses — your tenant scope, your data-access policy, the truncation guard, and the 1-hour query cache all apply, and one self-repair round re-writes the SQL if the first attempt fails. A single-intent ask is one sub-question (one group); a multi-intent ask (e.g. "the objections we hit from 11x **and** how deals with Acme are going") is split so each independent question gets its own well-formed query instead of one conflated `WHERE` clause. See [Multi-intent asks](#multi-intent-asks).
- **`blended`** does everything `internal` does **and** fans out a quick `web` + `news` citation pass under a tight deadline. The web query is not your raw ask verbatim: a cached, tenant-aware enrichment rewrites it with your company/industry context first (so "enterprise deals" means _your_ pipeline segment, not car-rental coupons), and if the ask reads as purely a question about your own workspace data the external pass is skipped with `external_omitted: "not_relevant"` instead of returning off-topic links. There is deliberately **no rerank and no fusion** here — the blended pass is built into the fast lane as a quick citation layer; the heavier market synthesis (and the internal-vs-market divergence map) belongs to [Chat](chat.md). Blended needs the `external_search:execute` scope; without it the call quietly degrades to internal-only and sets `external_omitted: "missing_scope"` — it never fails for the missing scope.

## The result shape

Search never hands back a raw error or stack. Past parameter validation it always returns `success: true`; every failure mode is a typed field on the result, and a **partial answer always beats a 500**.

```json
{
  "success": true,
  "query": "objection-tagged calls in the last 30 days",
  "mode": "blended",
  "internal": {
    "status": "ok",
    "sql": "SELECT objection, COUNT(*) AS n FROM interactions WHERE …",
    "explanation": "Counts objection-tagged interactions in the last 30 days.",
    "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",
        "sql": "SELECT …",
        "rows": [{ "objection": "pricing", "n": 42 }],
        "row_count": 1,
        "note": null
      }
    }
  ],
  "external": {
    "citations": [{ "title": "…", "url": "https://…", "snippet": "…", "source": "web" }],
    "sources_timed_out": []
  },
  "external_omitted": null,
  "synthesis": null,
  "message": "Found 1 result over your interactions.",
  "coverage": {
    "latest_event_at": "2026-05-19T05:01:50Z",
    "latest_ingest_at": "2026-05-19T05:01:50Z",
    "total_rows": 67538,
    "days_behind": 1,
    "is_stale": false
  },
  "escalate_to_chat": false,
  "escalate_reason": null
}
```

### Reading it

- **`internal.status`** is the warehouse leg's outcome:
  - `ok` — SQL ran and returned rows (in `internal.rows`).
  - `empty` — SQL ran, zero rows matched. The ask is answerable; nothing fit. `internal.note` explains, and `internal.sql` shows what ran.
  - `unsupported` — the ask does not fit a single SELECT over the interactions surface, so no SQL was written. `escalate_to_chat` is `true`.
  - `failed` — SQL was written but execution failed after one self-repair round (or the phase ran out of time). `internal.note` is a plain-language explanation — never the raw database error.
- **`groups`** is the fanned-out result — one entry per planned sub-question, each with a `label`, the `question` it answered, and its own `internal` block (same shape as the top-level `internal`). A single-intent ask has one group whose `internal` is the same object as the top-level `internal`; render `groups` when you want the per-question breakdown, or keep reading the flat `internal` for the primary result. See [Multi-intent asks](#multi-intent-asks).
- **`internal`** is the PRIMARY group's warehouse result — always `groups[0].internal`. It is retained as a flat field so existing callers keep working without reading `groups`.
- **`internal.sql`** is the SQL that ran (or the last attempt) — the receipt behind `internal.rows`. Show the rows as the snapshot; for a deterministic re-run of the same slice, express it as typed filters via [`POST /search/query`](endpoints/search.md) (raw SQL is not directly executable on the public surface).
- **`internal.truncated`** is `true` when the row set was capped at `limit`; **`cached`** is `true` when the 1-hour cache served it; **`repaired`** is `true` when the self-repair round rewrote the SQL.
- **`external`** is present only on the `blended` path when the external leg ran (`null` otherwise). Any source that missed its deadline is listed by id in `external.sources_timed_out` — a slow source loses a citation, never the whole call. `external.external_query_used` shows the enriched query the web pass actually ran.
- **`external_omitted`** is `"missing_scope"` when you asked for `blended` but lack `external_search:execute`, or `"not_relevant"` when the ask reads as purely a workspace-data question that public web search cannot help answer; `null` otherwise.
- **`synthesis`** is the opt-in one-paragraph headline (only when you passed `synthesize: true` and it succeeded; `null` otherwise).
- **`escalate_to_chat`** is `true` when the ask is out of the fast lane's reach and [Chat](chat.md) is the better door — any sub-question came back `unsupported`/`failed`, or the planner spotted an intent the warehouse cannot answer.
- **`escalate_reason`** is a short plain-language reason Chat is the better door — set when the planner detected a non-data intent (advice, a recommendation, a "what should I say" ask, or a source beyond the interactions table). `null` when there is none. It is additive: `escalate_to_chat` can be `true` with `escalate_reason` `null` (e.g. a failed query), and the data groups it _could_ answer still come back either way.
- **`coverage`** is how current your warehouse data is — `latest_event_at` (the newest call/meeting/email), `latest_ingest_at` (the newest sync), `total_rows`, `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, the `message` also folds in a plain-language note so an empty result reads as a coverage gap ("your data spans through May 19") rather than a mysterious failure. `null` only when coverage could not be resolved (the search still returns).

## How the fast lane answers (the lanes)

Every warehouse ask is offered to three tiers in order, so the common questions never pay for an LLM writing SQL under a stopwatch:

1. **Template lane** — a curated, schema-pinned SQL shape for the recurring GTM asks (competitor mentions by account, the rival landscape + win rate, objections, pain points, feature requests, champions, economic buyers). These filter a **materialized signal** the pipeline already computed (e.g. `sentiment_primary = 'competitive_mention'` for competitor mentions — never a raw-text scan), so they are sub-second and correct by construction. When a template answers, `internal.lane` is `"template"` and `internal.template_id` names it.
2. **Semantic lane** — a theme-shaped ask ("what are customers saying about X", "common themes") is answered from the ML cluster index rather than a row query. `internal.lane` is `"semantic"`.
3. **Free-form lane** — anything the first two do not cover falls through to the NL→SQL writer (`internal.lane` is `"freeform"` or absent). It is taught the materialized-signal columns so it prefers them over a text scan, its call is bounded + cancellable, and it self-repairs once before giving an honest `failed`.

You do not choose a lane — it is picked for you. `internal.lane` / `internal.template_id` are there for observability, not required reading.

## Multi-intent asks

A single ask can carry more than one independent data question. "The objections we hit from 11x, and how are deals with Acme going?" is really two: one over 11x interactions, one over Acme's. The fast lane **plans** the ask into the fewest independent sub-questions (capped at 3), writes SQL for each, and runs them in parallel — so each question gets its own well-formed query instead of one conflated `company IN ('11x','acme')` that returns nothing.

Each sub-question lands as an entry in `groups` with its own `label`, `question`, and `internal` result. A single-intent ask is one group (byte-identical to before). If part of your ask is **not** a data question — "…and what should I say to their VP?" — the fast lane does not invent a query for it: it sets `escalate_to_chat` with an `escalate_reason` pointing you at [Chat](chat.md), while still returning the data groups it _could_ answer. The whole call still settles under the ~15s ceiling: the sub-questions run concurrently, not one after another.

The only `success: false` shape is a parameter-validation failure:

```json
{
  "success": false,
  "error": { "code": "invalid_argument", "message": "Invalid search request: …" }
}
```

## Over MCP

The same fast lane is the `run` action of the `search` tool on the Amdahl MCP server, with the same parameters:

```json
{ "action": "run", "query": "top objections this quarter", "mode": "blended" }
```

The `search` tool carries three actions: `run` (this lane), `query` (the routed structured/semantic verb), and `fields` (the filter-field catalog) — the latter two are documented in the [Search endpoint guide](endpoints/search.md). Use `search` when you want data back in one call; use the [`agents` tool's Chat actions](chat.md#over-mcp) (`start_chat` / `chat_status` / `respond` / `cancel_chat`) when you want Amdahl to run a multi-step investigation server-side. The result is data only — no directive text.

## Scopes

`data:read` is all `internal` needs, so a read-only key can call it. The `blended` leg additionally checks `external_search:execute` and degrades to internal-only when it is absent. Both scopes are on the customer-agent key bundle.
