# Query Spec

Section: API reference
Source: https://platform.metix.ai/docs/api/query-spec
Every page in one file: https://platform.metix.ai/llms-full.txt

One grammar searches all three datasets. It is a small boolean tree of field conditions, and the same request shape works against people, jobs, and companies.

## The shape of a query

Every structured search sends a `where` tree and an optional page size. The tree is built from two things: composers, which combine conditions, and leaves, which test one field.

```json
{
  "where": {"all": [
    {"field": "<name>", "<operator>": <value>}
  ]},
  "size": 25
}
```

A leaf names one field and applies one operator to it. Everything else in the grammar is a way of arranging leaves.

A query must carry at least one condition. An empty tree is refused with `At least one search condition is required` rather than returning the whole corpus.

## Composing conditions

| Composer | Takes | Matches when |
| --- | --- | --- |
| all | An array of nodes | Every node matches. This is the usual outer wrapper |
| any | An array of nodes | At least one node matches |
| not | A single node, or an array | The node does not match. An array negates all of it |

Composers nest, so an `all` can contain an `any` that contains a `not`. This example asks for a company in the United States that is either mid-sized or well funded, and is not publicly listed.

```json
{
  "where": {"all": [
    {"field": "hq_country", "match": "United States"},
    {"any": [
      {"field": "employees_count", "gte": 200},
      {"field": "last_funding_round_amount_raised", "gte": 50000000}
    ]},
    {"not": {"field": "type", "eq": "Public Company"}}
  ]},
  "size": 50
}
```

## Operators

| Operator | Takes | Means |
| --- | --- | --- |
| eq | One value | Exact match on the whole value |
| in | A non-empty array | Matches any one of the listed values |
| match | Text | Analyzed text match. Every word must be present |
| gte | A number, or a date on a date field | At or above |
| lte | A number, or a date on a date field | At or below |
| exists | true | The field is present on the record |

> **One operator per leaf**
>
> A leaf carries exactly one operator. A bounded range is therefore an `all` of two leaves over the same field, not `gte` and `lte` in one node. This is the mistake nearly every caller makes first, and it is refused with `Each field condition takes exactly one operator`rather than silently applying one half of the range.

`employees_count` below is a company field. Field names are not shared between the three datasets, so a leaf only means anything against the endpoint that publishes its field.

```jsonc
// Wrong. Two operators in one leaf.
{"field": "employees_count", "gte": 50, "lte": 500}

// Right. Two leaves, combined.
{"all": [
  {"field": "employees_count", "gte": 50},
  {"field": "employees_count", "lte": 500}
]}
```

Operators are also matched to the kind of field. Numeric and date fields accept `gte`, `lte`, and `exists` only; text and category fields accept `eq`, `in`, `match`, and `exists`. Asking a numeric field for `eq`, or passing `"50"` as a string where a number is expected, is refused rather than reinterpreted. Each dataset page lists the operators its fields accept.

`exists` has one form. To ask for records where a field is absent, wrap the leaf: `{"not": {"field": "salary_min", "exists": true}}`.

## Exact fields are case-sensitive

A text field matches text, where every word in your value has to appear. An exact field compares the whole stored value, character for character, so `"director"` matches nothing where `"Director"` matches.

> **The one mistake that raises no error**
>
> Every other error in this grammar is refused before the query runs. Wrong case is not an error: the query is valid, it executes, and it matches zero records. A result of zero reads as "nobody like that exists" rather than "wrong case", which is why it is worth naming here rather than leaving to be discovered.

Several fields draw from a fixed list, and the lists live with the fields rather than here: [People](https://platform.metix.ai/docs/api/people.md), [Jobs](https://platform.metix.ai/docs/api/jobs.md) and [Companies](https://platform.metix.ai/docs/api/companies.md) each carry the values for their own. One copy per value, on the page you were already reading to find the field.

Several of those values contain commas — `Oil, Gas, and Mining` is one value, not three — which is the other half of why they are shown in a table there rather than run together in a sentence here.

## Asking a yes/no field for no

`true` means the same thing on all six of them. `false` does not, because a field that is only written when it is true has no `false` to match: it is absent instead, and absent is not a value you can ask for with `eq`.

| Field | How to ask for no |
| --- | --- |
| is_working, is_decision_maker, is_current | false. Both answers are stored, so both can be asked for |
| is_studying, is_b2b | not, wrapping the positive: the field is absent rather than false, so eq false matches nothing |
| application_active | Nothing to ask. Every job in the searchable index is active, so this condition removes no results today |

```jsonc
// Matches nothing: there is no stored false to match.
{"where": {"field": "is_b2b", "eq": false}}

// The companies that are not B2B.
{"where": {"not": {"field": "is_b2b", "eq": true}}}
```

> **Why this is worth a section**
>
> A query built the first way is valid, costs nothing, and returns zero. Like wrong case, it reads as an answer about the data rather than about the query. The counts behind this table were read off the indexes the search actually uses, so they describe what is stored rather than what the field names imply.

## Relative dates

Date fields take an absolute `YYYY-MM-DD` or a relative value that the server resolves to an absolute day before the query runs. That means a saved query keeps meaning "the last 30 days" instead of freezing on the day it was written.

| Form | Example | Maximum offset |
| --- | --- | --- |
| now | now | Not applicable |
| now-<n>d | now-30d | 7300 days |
| now-<n>m | now-6m | 240 months |
| now-<n>y | now-5y | 20 years |

```json
{"where": {"all": [
  {"field": "posted", "gte": "now-30d"},
  {"field": "employment_type", "match": "Contract"}
]}}
```

A relative value on a field that is not a date is refused. The date fields are marked as such in each dataset's field table.

## Same-record scopes

A profile holds many jobs and many degrees. Without a scope, two conditions can be satisfied by two different records: one job at Google, and a separate job as a director somewhere else. A scope forces both to land on the same record.

```jsonc
// "Was a director at Google": both conditions, one job.
{"where": {"has_experience": {"all": [
  {"field": "company_name", "match": "Google"},
  {"field": "level", "eq": "Director"}
]}}}

// "Worked at Google, and was a director somewhere":
// two conditions, possibly two different jobs.
{"where": {"all": [
  {"field": "company_name", "match": "Google"},
  {"field": "level", "eq": "Director"}
]}}
```

| Scope | Groups | Available on |
| --- | --- | --- |
| has_experience | One job: employer, title, level, dates, and workplace location | People |
| has_education | One degree: institution, major, level, and years | People |
| has_language | One language entry | People |

People are the only entity with scopes. A job document is one job and a company document is one company, so there is nothing to group. Scopes also do not nest inside one another, and each scope accepts only the fields that belong to it.

## Paging with after and next

A search response includes `next` while more results remain. Pass that value back as `after`, with the same `where` tree, to get the following page. On the last page `next` is absent rather than empty, so its absence is how you know you are done.

```jsonc
// Page 1 response
{"data": {"job_ids": ["Jb71xKcAoP"], "next": "WzE3MjQwMDAwMDAsIkpiNzF4S2NBb1AiXQ"}}

// Page 2 request
{"where": {"all": [{"field": "title", "match": "data engineer"}]},
 "size": 100,
 "after": "WzE3MjQwMDAwMDAsIkpiNzF4S2NBb1AiXQ"}
```

> **Pages are not a snapshot**
>
> There is no point-in-time. The cursor carries the sort position of the last result and nothing else, so the server stores no state and a cursor does not expire. The cost of that is consistency between pages: a record indexed while you are paging can appear on a later page, move, or be missed entirely. If you need an exact set, narrow the query rather than paging deeper.

## Limits

| Limit | Value | What happens past it |
| --- | --- | --- |
| Nesting depth | 6 | The query is refused before it runs |
| Conditions per query | 64 | The query is refused before it runs |
| Results per page | 100 by default, 10,000 maximum | Values above the maximum are rejected |
| IDs per detail request | 100 | Split the IDs across several calls |

A field name that is not in the dataset's list is refused. The three datasets do not share one vocabulary, so check the name against the page for the endpoint you are calling. A translation failure costs nothing: the query is checked before any Credit is charged.

## Where the field names live

The grammar is shared; the vocabulary is not. Each dataset publishes its own field list with the operators each field accepts, and a name from one dataset is not valid in another.

[People fields](https://platform.metix.ai/docs/api/people.md) [Job fields](https://platform.metix.ai/docs/api/jobs.md) [Company fields](https://platform.metix.ai/docs/api/companies.md)

What a search can filter on and what a detail response can return are two different lists. A field may be returnable without being queryable, and the query vocabulary is deliberately the smaller of the two.
