# Candidate Search with the Core API

Use this guide for candidate list, count, and grouped-count requests:

- `GET /core/candidates`
- `GET /core/candidates/count`
- `GET /core/candidates/counts`

This API-owned route serves the guide as Markdown for API consumers and AI agents:

`https://api.quake.dev/documentation/guides/candidates/search-guide`

## Request model

Candidate search separates relevance, eligibility, and ordering:

| Parameter         | Purpose                                             |
| ----------------- | --------------------------------------------------- |
| `search`          | Plain-text or explicit Elasticsearch Boolean search |
| `filters`         | Structured constraints, including location          |
| `vector_search`   | Semantic candidate-profile retrieval                |
| `sort`            | Explicit result ordering                            |
| `select`          | Fields returned by the list endpoint                |
| `limit`, `offset` | Pagination                                          |

`search` and `filters` are JSON-encoded query-string parameters. `sort` and `select` are comma-separated strings.

```bash
curl --get 'https://api.quake.dev/core/candidates' \
  --header 'Authorization: Bearer <token>' \
  --data-urlencode 'limit=30' \
  --data-urlencode 'offset=0' \
  --data-urlencode 'search={"search_text":"registered nurse"}' \
  --data-urlencode 'filters={"statuses":["active"]}' \
  --data-urlencode 'sort=-_score,-created_at' \
  --data-urlencode 'select=id,first_name,last_name,email,status,created_at'
```

The list response contains an exact total for the complete query, independent of pagination:

```json
{
  "total": 42,
  "data": [],
  "meta": {
    "warnings": []
  }
}
```

## Plain-text search

Send plain text inside `search.search_text`:

```json
{
  "search_text": "jas"
}
```

Plain-text search does not parse occupations, locations, distance phrases, or Boolean operators. For example,
`"nurse near Bath"` is treated as ordinary text. Send the occupation in `search_text` and the location in
`filters.location` instead.

### What plain text matches

Every plain-text query searches `search_document` with normal weight. The indexed document contains:

- first and last name
- email
- candidate location text
- work titles, employers, and descriptions
- education degrees, subjects, and institutions
- skills
- custom attributes configured as searchable
- candidate description in the Elasticsearch document

`remote_id` is not part of `search_document`; it has a separate exact-match clause for one-word queries.

One- and two-word queries also receive boosted name matching:

- 1-2 character name terms use prefix matching only
- 3+ character name terms use trigram matching plus low-weight phonetic matching
- each token in a two-word query follows its own length rule
- hyphens and apostrophes are valid name characters
- normal first-name/last-name order ranks above reversed order

This means `jas` can match `Jasmin`, while `ja` can match it through prefix matching.

One-word queries also try a case-insensitive exact `remote_id` match. It is the strongest relevance signal.

### Relative relevance weights

| Match                          | Boost |
| ------------------------------ | ----: |
| Exact one-word `remote_id`     |    20 |
| Two-token name, normal order   |    10 |
| One-token name                 |     6 |
| Two-token name, reversed order |     2 |
| `search_document`              |     1 |
| Phonetic name match            |   0.2 |

These boosts influence Elasticsearch `_score`; they do not guarantee a fixed position when other matching clauses or
explicit sorts are present.

The legacy `search.fields` property remains accepted for compatibility but is deprecated and has no effect on
`search_text`.

## Boolean and vector search

Use `search.boolean_query` when the caller intentionally provides Elasticsearch `query_string` syntax:

```json
{
  "boolean_query": "status:active AND skill_labels:\"registered nurse\""
}
```

`boolean_query` takes precedence over `search_text`. Its existing field behavior and syntax remain unchanged.

Use `vector_search` for semantic profile retrieval:

```text
vector_search=ICU nurse with trauma experience
```

Vector search remains unchanged. When combined with `search` or `filters`, all supplied constraints must match.

## Location search

Location is a filter, not a feature of `search_text`. Prefer the most structured input the caller already knows.

### Known coordinates

```json
{
  "location": {
    "latitude": 51.3811,
    "longitude": -2.359,
    "radius_miles": 25,
    "min_confidence": 0.8
  }
}
```

Latitude and longitude must be supplied together. `radius_miles` must be greater than `0` and no more than `100`.
Candidates without an indexed point are excluded from a spatial match.

### Known structured place

Use one or more normalized place properties when the caller wants an administrative-place filter rather than a radius:

```json
{
  "location": {
    "city": "Bath",
    "admin1": "England",
    "country_code": "GB"
  }
}
```

Supported structured properties are `city`, `admin1`, `country_code`, `postcode`, and `place_id`.

### Human-readable place

Use `filters.location.query` when the caller has text that needs geocoding:

```json
{
  "location": {
    "query": "Bath, England",
    "radius_miles": 25,
    "min_confidence": 0.8
  }
}
```

`query` is trimmed and must contain 1-200 characters. It always means a radius search around the resolved point.
`radius_miles` defaults to `25` and has a maximum of `100`. For country-wide matching, use `country_code` rather than a
large radius.

`query` may be combined only with `radius_miles` and `min_confidence`. Combining it with coordinates or normalized place
properties returns a descriptive `400 invalid_filters` response.

Quake tries providers in this order:

1. Geoapify
2. AWS Location with single-use intent

Single-use AWS results are not persisted or cached.

When a location resolves, Quake applies both:

- an Elasticsearch `geo_distance` filter around the resolved point
- `location_confidence >= min_confidence`, defaulting to `0.8`

### Geocoding fallback

If both providers fail, return no suitable result, or return an unsuitable ambiguous/low-confidence result, the request
does not fail. Quake falls back to analyzed `location_text` matching with all location-query terms required.

The fallback cannot honor `radius_miles`. If `distance` sorting was requested, that sort is omitted and remaining sorts
still apply. The response warning tells the caller that behavior changed.

Invalid caller input, such as a radius of `0`, a negative radius, or a radius greater than `100`, is not a provider
failure and returns `400 invalid_filters` instead of falling back.

## Sorting

Sort entries are applied left to right:

- bare column: ascending
- `-column`: descending

Examples:

```text
sort=-_score,-created_at
sort=distance,-_score,-created_at
sort=-updated_at
```

Supported candidate list sort columns are:

- `_score`
- `distance`
- `created_at`
- `updated_at`
- `last_email_sent`
- `last_email_received`
- `status`
- `display_name`
- `similarity`
- sortable custom attributes as `meta.attributes.<attribute-id>`

Missing values sort last. Use the configured attribute ID for custom-attribute sorts; only attributes supported by the
resource's sort validation can be sorted. `similarity` is meaningful only with `vector_search`.

For plain-text search with no explicit sort, the default is:

1. `_score DESC`
2. `created_at DESC`
3. `id DESC` as a deterministic tiebreaker

An explicit sort is authoritative. `_score` is permitted with `search.search_text`, `search.boolean_query`, or
`vector_search`. Without a scoring query it returns:

```json
{
  "error": "invalid_sort",
  "message": "_score sort requires search.search_text, search.boolean_query, or vector_search"
}
```

### Distance sort

Use `distance` for nearest-first ordering or `-distance` for farthest-first ordering. Distance sorting requires either:

- `filters.location.latitude` and `filters.location.longitude`; or
- a successfully geocoded `filters.location.query`

Without either input, the API returns:

```json
{
  "error": "invalid_sort",
  "message": "distance sort requires filters.location.query or filters.location.latitude and filters.location.longitude"
}
```

The Elasticsearch sort uses `location_point`, miles, arc distance, and the minimum distance for multi-valued points.
Missing or unmapped points sort last.

Distance is a filter/sort concern and does not change `_score`. To combine textual relevance with proximity, request both
explicitly, for example `sort=-_score,distance,-created_at`.

This contract does not add a computed distance field to candidate response items.

## Location warnings

List, count, and grouped-count responses always include `meta.warnings`, using `[]` when there are no warnings.

List:

```json
{
  "total": 12,
  "data": [],
  "meta": {
    "warnings": []
  }
}
```

Count:

```json
{
  "count": 12,
  "meta": {
    "warnings": []
  }
}
```

Grouped count:

```json
{
  "buckets": [],
  "total_buckets": 0,
  "meta": {
    "warnings": []
  }
}
```

Geocoding fallback warning:

```json
{
  "code": "location_geocoding_fallback",
  "message": "Location could not be geocoded; radius was not applied and location text matching was used.",
  "details": {
    "location_query": "Bath, England",
    "requested_radius_miles": 25,
    "fallback": "location_text"
  }
}
```

When distance sorting was requested, the message states that both radius and distance sorting were not applied. Provider
failure details are logged internally and are not exposed to consumers.

## Complete location example

Search for active nurses within 25 miles of Bath, prioritize relevance, then proximity:

```bash
curl --get 'https://api.quake.dev/core/candidates' \
  --header 'Authorization: Bearer <token>' \
  --data-urlencode 'limit=30' \
  --data-urlencode 'offset=0' \
  --data-urlencode 'search={"search_text":"nurse"}' \
  --data-urlencode 'filters={"statuses":["active"],"location":{"query":"Bath, England","radius_miles":25}}' \
  --data-urlencode 'sort=-_score,distance,-created_at' \
  --data-urlencode 'select=id,first_name,last_name,email,location,status,remote_id,created_at'
```

## Guidance for API consumers and AI agents

- Put professional/profile terms in `search.search_text`.
- Never expect `search_text` to infer `near`, `in`, or a distance expression.
- Put a human-readable place in `filters.location.query`.
- Prefer coordinates or normalized place fields when already known; do not geocode them again.
- Use `country_code` for country-wide intent.
- Inspect `meta.warnings` before presenting location-sensitive results.
- Treat a location fallback as text matching, not proof that a candidate is within the requested radius.
- Use `_score` for relevance and `distance` for proximity; one does not affect the other.
- Use `select` to limit payload size and request the fields the caller will actually consume.
- Keep `offset + limit` at or below `10,000`.
- Expect newly written candidates to take a short time to appear because Elasticsearch indexing is asynchronous.

For limits, external-ID lookups, and differences from other resources, see
[Search, filters, and pagination](https://api.quake.dev/documentation/guides/query-and-pagination). The candidate list
defaults to `limit=100`, accepts `limit` from 1 to 1,000, and enforces `offset + limit <= 10,000`. Offset pages are not a
frozen snapshot; concurrent writes and asynchronous indexing can shift results.

For authentication and user-scoped access, see
[Delegated OpenAPI Sessions](https://app.quake.dev/docs/openapi/delegated-openapi-sessions.md). The complete
machine-readable API schema is available at [OpenAPI JSON](https://api.quake.dev/openapi.json).
