Docs › Reference

For everyone

Ask and search

The API calls behind ask and search.

Part of the HTTP API reference.

Route: /^#\/(?:ask|search)(?:\/(.*))?$/ → showAsk(q) . #/search still answers "because links and bookmarks were made with it."

The address carries the question and the conditions: #/ask/<urlencoded question>?c=<json conditions>&in=<uids>&drop=<keys>&t=<TYPE>&wide=1&plain=1 — "so a filtered search is a link somebody can paste into a ticket and open to the same list."

The order of calls

  1. GET /api/facets — only if the address carried conditions (facetCatalogue), cached in the client for the session.
  2. GET /api/views — with no type, so it returns the saved searches (loadSearches). Always, because it is what the empty screen offers and what the Save bar compares against.
  3. GET /api/ask/home — on the empty screen only (loadAskHome).
  4. Then one of two, decided by whether there are conditions: - no conditions → GET /api/search (runSearch) - any conditions → GET /api/find (runFind)
  5. GET /api/assets/{uid}?peek=true — lazily, for the best-match card (bestCard).
  6. Save bar, on demand: POST /api/views, PUT /api/views/{uid}, DELETE /api/views/{uid} .

Both search endpoints carry a generation token: const mine = ++SEARCH.seq, and a reply that comes back to find a newer one is dropped. "Typing while a find is in flight sent a second, and whichever answer landed last was painted — which is often the first one, because a narrower filter comes back sooner."

GET /api/ask/home — the page before anybody types

Docstring: "what to try, what is in here, and what this person was looking at last. No per-connection freshness line. It was here, and it does not scale: a repository with thirty connections would open on a paragraph about them. When each was last harvested belongs on the Sources screen."

No parameters.

Everything except recents is cached by _derived under the key ask_home — 0.63s warm at ten million. recents stays outside the cache "because it is the one thing here that depends on who is asking, and _derived must never hold one of those."

Response

{
  "total": 10001150,          // count(*) FROM asset
  "groups": [                 // one per ROLLUP_TYPES code
    {"code": "DATA_DOMAIN", "name": "Data Domain", "plural": "Data Domains",
     "cards": [               // biggest first, then by name
       {"uid": "…", "name": "Customer", "n": 4120,
        "kinds": [            // sorted: authored wholes, then parts, then physical
          {"code": "TABLE", "plural": "Tables", "origin": "harvested",
           "part": true,      // whether the instances sit inside something
           "n": 212}
        ]}
     ]}
  ],
  "examples": [               // EXAMPLES_SHOWN = 5 at most
    {"q": "Tables with PII in Banking", "from": "model"}  // "model" = generated
  ],
  "recents": [                // LIMIT 8, this person only, newest first
    {"uid": "…", "name": "CUSTOMERS", "type_name": "Table",
     "icon": "table", "viewed_at": "…"}
  ]
}

Why the examples are built, not canned (_generated_examples): "Nothing here is a canned sentence about customers. Each candidate is made from what is actually in the repository — its marks, its groupings, its terms, who is responsible for what — and then RUN, and kept only if it answers. An example that returns nothing is worse than no example: it is the first thing a new person tries, and it teaches them the box is broken."

recents is filled by _saw, called from GET /api/assets/{uid} unless peek=true. It records the person actually signed in, not whoever is being viewed as: "Looking through a colleague's eyes must not fill their list with what YOU looked at." Kept to RECENT_KEEP = 30 rows per person.

GET /api/search — the box

Query parameters

Name Type Default Allowed / clamped to What it does
q string "" — the question, as typed
type string null an asset type code forces the kind, overriding what the question said
limit int 40 not clamped here; passed into the statement page size
prefix bool true — whether the last word is matched as a prefix
plain bool false — skip the question parser; just the words
wide bool false — drop the type the question named — "the facet row saying 'show me everything again'"
within string null comma-separated grouping uids the In Data Domain rail
drop string null comma-separated chip keys take one part of the reading off

drop keys are the drop field on each understood chip: "type", "filter:<facet>", "in:<uid>", "from:<key>".

Refusals: a within uid that is not a UUID → 400 "'<uid>' is not an asset id."; one that is not an asset of a ROLLUP_TYPES type → 400 "'<uid>' is not something results can be narrowed to."

Permission: a session. The search reads asset_context, which holds everything; there is no per-asset read check.

Docstring: "The search, and then what the screen needs to lay it out. Kept apart from the reading and ranking so the laying-out cannot change which answers come back: every branch below — a list, a 'where', a 'related' — is decorated the same way afterwards."

Response — one shape, several intents.

{
  "q": "where is customer email",
  "terms": ["customer", "email"],   // what was left after the question was read
  "expanded": [                     // glossary alternatives that were also searched
    {"term": "client", "means": "Customer"}
  ],
  "hits": [
    {"uid": "…", "name": "EMAIL", "key": "PROD.SALES.CUSTOMERS.EMAIL",
     "type_code": "COLUMN", "type_name": "Column",
     "summary": "…",                // DEFINITION or DESCRIPTION
     "score": 0.8213,
     "on_itself": true,             // the words matched this asset, not its surroundings
     "why": "…",                    // a sentence saying how it matched
     "signals": {},                 // the context layer's own ranking inputs
     "marks": [                     // whatever this client declared worth flagging
       {"code": "CONTAINS_PII", "name": "Contains PII", "value": "Yes"}
     ],
     // added by _decorate: the icon, the domains, the containment path
     "icon": "column", "domains": […], "path": […]}
  ],
  "facets": [                       // counts by type, OVER THE WHOLE MATCH SET
    {"code": "COLUMN", "name": "Column", "plural": "Columns", "n": 81}
  ],
  "domains": [                      // the In Data Domain rail, over the whole match set
    {"uid": "…", "name": "Customer", "type_code": "DATA_DOMAIN",
     "type_name": "Data Domain", "n": 44}
  ],
  "total": 81,                      // the match count; the type's own count when a type was named
  "fell_back": false,               // true when the strict match found nothing and it widened
  "matched": "all words",           // or "some words"
  "understood": [                   // what the parser made of the question, as chips
    {"kind": "words"|"subject"|"type"|"filter"|"in"|"from",
     "label": "…", "drop": "in:<uid>"}
  ],
  "intent": "where",                // list | where | count | compute | related | …
  "nearest": null,                  // see below
  "answer": {…},                    // see below
  "best": "<uid>",                  // the best-match card, or null
  "same_name": ["<uid>", …]         // other hits with exactly that name
}

best and same_name (_best_match): suppressed entirely when the question narrowed — to a kind, a mark or a domain — because "a question that narrowed asked for a list, and the list is the answer. A best match card over it would be the first row wearing a badge." Otherwise the top hit is offered only if its normalised name equals the question, or it is the only hit, or it scores at least 1.5× the second.

answer is built per intent. For intent: "where":

{"kind": "where", "uid": "…", "name": "EMAIL", "type": "Column",
 "gap": false,                                 // true when nothing physical is linked
 "query": "SELECT … FROM PROD.SALES.CUSTOMERS",// the name to query, and who could read it
 "text": "EMAIL is in CUSTOMERS in SALES in PROD."}

For intent: "compute" the answer is a composed semantic query, never executed. For intent: "count" the answer is given even when it is zero — "a count question left with an empty sentence reads as a broken screen rather than as zero, and zero is exactly what somebody is checking for after setting a source up." Every other kind of answer stands down in favour of the closest-answers panel when nearest is set.

nearest: "Never a dead end. A question that named conditions and found nothing has nearly always found something ADJACENT — the mark is on the columns, not on the tables; the terms are in another domain — and saying 'nothing' hides the answer one step away." When it is set, total becomes the count of the closest answers and the sentence's own count is 0, because they are different numbers.

Why the rail has two implementations: under RAIL_FROM_ANSWER = 20_000 matches it walks up from the assets that matched; above it, meeting the whole estate coming down is cheaper. The number is a crossover, not a limit — the rail describes the whole match set either way. Measured: "this rail and the roll-up behind it were thirteen of the 14.6 seconds a search matching eighty assets took, while the search itself was 92ms."

Usage: a plain search (no within, no drop, not wide) records a search usage event carrying the result count and whether an answer was composed.

GET /api/find — a filtered list across the whole estate

Used instead of /api/search the moment there is one condition.

Docstring: "The conditions are hard and the text is soft: everything named has to hold, and the words only decide the order. Both go into one statement, so the limit applies to the answer rather than to the search."

Query parameters

Name Type Default Clamped to Notes
q string null — the words
conditions JSON string null — a list of {facet/key, op, value(s)}
view uid null — open a saved search by its id
limit int 50 max(1, min(limit, 200))
offset int 0 max(0, offset)
view fills in the conditions and the words **only where the caller gave
none — "a saved search is where somebody starts, not a cage." Conditions
passed alongside it win.

Refusals

Condition Status Message
conditions is not readable JSON 400 "The filters could not be read."
a condition the compiler cannot read 400 facets.Bad's own sentence
view names nothing 404 "There is no saved search with that id."

Response

{
  "q": "customer",
  "conditions": [ … ],         // as applied, including any from the view
  "rows": [
    {"uid": "…", "name": "CUSTOMERS", "external_key": "PROD.SALES.CUSTOMERS",
     "type_code": "TABLE", "type_name": "Table", "plural": "Tables",
     "source_status": "Missing in Source",   // properties->>'source_status', or null
     "absent_since": "2026-09-17",           // properties->>'absent_since', or null
     "summary": "…",
     "icon": …, "domains": …, "path": …}     // _decorate, as on Ask
  ],
  "total": 1204,               // _how_many — exact below 10000, a floor above
  "capped": false,
  "ceiling": 10000,            // COUNT_CEILING
  "by_type": [                 // the whole answer by type; NOT capped
    {"code": "TABLE", "plural": "Tables", "n": 412}
  ],
  "view": {                    // the saved search that was opened, or null
    "uid": "…", "name": "…", "description": "…", "filters": [ … ],
    "q": "…", "created_by": "…"
  },
  "understood": [ … ],         // only the chips that were APPLIED: words and subject
  "expanded": [ … ],
  "offset": 0,
  "limit": 50
}

by_type is deliberately uncapped: "the number of rows here is the number of asset types in the metamodel — 23 on the starter model, perhaps sixty at a large client — so there is nothing to protect against, and a cap made the chips add up to less than the total above them, which reads as an arithmetic error rather than as a truncation."

Ordering: t.display_order, a.name with no words; with words, the context layer's own rank descending then name — "A word match decides WHETHER something is an answer; this decides which answer comes first."

How the words are matched (_words_clause) — read this if you are reimplementing it, because two earlier versions were wrong:

  • %the whole phrase% works for one word and fails for every sentence.
  • plainto_tsquery over the raw question requires every word, including "who" and "owns", so a question matched nothing.

What it does instead: run the question through question.read against the metamodel's own vocabulary, search what is left, expand through the glossary as alternatives (never replacements), and assemble the three questions as a UNION of id sets rather than an OR:

a.id IN (SELECT b.id FROM asset b WHERE b.name ILIKE %(qlike)s
          UNION
         SELECT b.id FROM asset b WHERE b.external_key ILIKE %(qlike)s
          UNION
         SELECT ac.asset_id FROM asset_context ac WHERE ac.tsv @@ %(tsq)s::tsquery)

"Written as an OR none of the three indexes can be used… EXPLAIN on ten million assets: Seq Scan on asset, Rows Removed by Filter: 10,001,070 — the whole repository read to find eighty rows, 30.0s. And find asks the same question three times, for the page, the total and the counts by type, so the screen took 97.7s. As a UNION each half uses its own index — two trigram, one full text — and the ids are collected once: 30.0s to 0.03s, same eighty rows."

GET /api/facets — everything that can be filtered on

Called by facetCatalogue with no parameters, and by the workflow designer's scope block with ?types=<CODE>.

Query parameters

Name Type Default Notes
types string null comma-separated asset type codes, upper-cased

Docstring: "types narrows it… because once somebody has said 'Tables' there is no point offering them Ordinal Position, which no Table carries. Offering a condition that can only ever return nothing is the fastest way to make a filter feel broken."

Response

{
  "facets": [ … ],                 // facets.catalogue(cur, want) + facets.properties(...)
  "operators": [ … ],              // sorted(facets.OPERATORS)
  "needs_value": [ … ],            // operators that take one value
  "needs_values": [ … ]            // operators that take a list
}

Harvested properties are merged in from each connection's own mapping — "so a property cannot be choosable as a column and not as a condition." The individual facet shape lives in facets.py and is [not documented] here.

GET /api/views — the saved searches

Called with no type by loadSearches. The same endpoint with a type is the type-list's saved views; see Screen 3.

Docstring: "One endpoint because they are one table and one idea: a search is a view that is not pinned to a type. Two endpoints would be two lists to keep in step, and the first thing to go out of step would be who may change one."

Response with no type

{"type": null,
 "searches": [            // asset_type_id IS NULL, ordered by lower(name)
   {"uid": "…", "name": "Gone from Snowflake", "description": "…",
    "filters": [ … ], "q": "…", "created_by": "r.iyer", "updated_at": "…"}
 ],
 "views": [ … ]}          // the same list under the other name, for callers that want it

POST /api/views — save a search

Called by saveBar with no type_code, and by the type list's view bar with one.

Body — saving a search (no type_code):

Field Type Required Notes
name string yes stripped
filters list one of these two compiled by facets.compile to check it
q string one of these two stripped
description string no stripped, null when empty
columns list no ignored for a search

Refusals

Condition Status Message
no name 400 "A search needs a name — it is how other people will find it."
columns is not a list 400 "The columns have to be a list."
a condition the compiler refuses 400 facets.Bad's sentence
neither filters nor q 400 "A search needs a condition or some words. Saving one with neither saves the whole repository under a name."

Response: {"uid": "<new uid>"}.

created_by and updated_by are set to the acting username.

PUT /api/views/{uid} — change a saved search or view

Called by saveBar and by the type list's view bar .

Permission: the creator may always; anybody else needs METAMODEL_MANAGE — "Changing a view changes what everybody sees, so it is the creator's to change — or a metadata manager's, who is the one holding the tidying up."

Body — every field optional; at least one required.

Field Notes
name stripped; empty → 400 "A view needs a name."
columns must be a list; else 400 "The columns have to be a list."
filters stored as JSON; [] when falsy
q stripped, null when empty
hierarchy ignored unless the view is pinned to a type; cleaned by _clean_hierarchy
description stripped, null when empty

Refusals: 404 "There is no view with that id."; 400 "Nothing to change."

Response: {"uid": "<uid>"}.

DELETE /api/views/{uid}

Called by saveBar and the view bar.

Same permission rule as the PUT. 404 "There is no view with that id." Response {"removed": "<uid>"}.

GET /api/assets/{uid}?peek=true — the best-match card

Called by bestCard for the asset search named as best.

peek=true is the whole reason the parameter exists: "peek is a screen reading it for somebody who has not opened it — Ask's best-match card — and is not a visit, so it does not go into their recently viewed."

Full shape under Screen 4.