---
title: "search-api reference"
summary: "Every search-api route, its parameters and its response, generated from the source"
kind: generated
status: current
topic: api
order: 650
created: 2026-09-10
updated: 2026-10-07
digest: 3ffb5a5437f8f3c1a868b9aca6cf28b345529da2
---

# search-api reference

<!-- GENERATED FILE — do not hand-edit. Run `pnpm --filter @jobbtack/search-api docs:generate`
     (see apps/search-api/scripts/generate-api-docs.ts) after changing a route
     or a params/result interface; this regenerates from source, it doesn't
     track it automatically. Generated 2026-10-07. -->

Public at `https://jobbtack.se/api`: Caddy's `/api/*` proxy adds the bearer token to every request, so no key is needed there (`GET /api/describe` says the same, for outside readers).
Direct access, over Tailscale, takes a named key as `Authorization: Bearer <key>` on every route except `/health`, `/` and `/describe` (see the README).
See [`docs/architecture.md`](./architecture.md) for the deployment picture, and the MCP server (`apps/mcp-server`) for the same data exposed as tools for AI clients instead of raw HTTP. [`docs/api-conventions.md`](./api-conventions.md) is the shape a new field or route is meant to take, and its §15 lists where this reference still shows something else.

Query parameters come from each route's schema in `src/params.ts` (source of truth for this doc).
In a query string a `date` is an ISO 8601 datetime, or a date alone (`YYYY-MM-DD`), which means Swedish local midnight; a `boolean` is `true` or `false`; a key marked repeatable can be given more than once and its values are OR'd, while separate keys are AND'd; an empty value is the same as leaving the key out.
A key the route does not list, or a value it cannot read, is a 400 naming it (`{ "error": "unknown parameter: geo" }`).

Response types below are the actual TS interfaces (source of truth for this doc) — `Date` fields serialize as ISO 8601 strings on the wire, same as any `JSON.stringify`'d `Date` (JSON itself has no date type).

**Markdown for models.**
`Accept: text/markdown` makes `/search`, `/jobs`, `/jobs/:id`, `/jobs/:id/similar`, `/employers`, `/employers/:organizationNumber`, `/places/:code`, `/occupations/:conceptId`, `/tags` and `/tag-facets` answer with the page the MCP server's tool gives for the same answer (one line per ad, a heading per resource, what is folded away counted with the request that opens it, written relative to this root: `GET /search?tag=geo:0180`).
JSON stays the default; every other route, and every error, answers JSON whatever Accept says ([`docs/api-conventions.md`](./api-conventions.md) §17).

---

## GET /

What this API is.
Unauthenticated, like `/health` — the answer at https://jobbtack.se/api/.
Returns `{ name, description, status: "prototype", notice, baseUrl, mcp, endpoints }`, where `endpoints` is every route as `{ method, path, summary }`, read from Hono's `app.routes` (summaries from ROUTE_SUMMARIES in app.ts, null for a route that has none yet).

---

## GET /health

Liveness probe.
Unauthenticated (every route except this, `/` and `/describe` requires the bearer token).
Returns `{ ok: true }`.

---

## GET /describe

Every route, its parameters and a working request, from the schemas the routes read with (describe.ts; docs/backlog/220).
Open like / and /health.

Unauthenticated, like `/health` and `/`: the API describing itself for a public page, a client or a model — every route, its parameters from the same schemas the routes read with, and one example request that works as written (a test runs each).

**Response**

This API describing itself: its routes, their parameters and the types they return.

| Field | Type | Description |
|---|---|---|
| `name` | `string` |  |
| `description` | `string` | What the API serves and where its data comes from. |
| `status` | `string` | The API's maturity; `prototype` means no stability guarantees (see `notice`). |
| `notice` | `string` | What `status` means for a client. |
| `baseUrl` | `string` | The URL every route path is relative to. |
| `mcp` | `string` | The MCP server over the same data, for AI clients. |
| `authentication` | `string` | Whether requests need a key. |
| `notes` | `string[]` | What holds across routes, one topic per note: query strings, dates, tags, the filter grammar, typed text, response shapes. |
| `routes` | [`RouteDescription`](#routedescription)[] | Every route, with its parameters, an example request and its response type. |
| `types` | `Record<string, TypeDescription>` | Every type a response is made of, by name: its fields, their types and what each means. A field's type names another entry here, or is plain (string, number, boolean, null, a union of literals). |

---

## GET /search

**Query parameters**

| Query key | Field | Type | Description |
|---|---|---|---|
| `q` | `q`? | `string` | Free text, matched against the ad's headline and description. How it matches and ranks depends on `mode`. Each word also matches as a prefix, so a half-typed word works. Without `q`, results are newest first. |
| `includeSimilarOccupations` | `includeSimilarOccupations`? | `boolean` | Widen each occupation name in `tag` (an `occupation-name` key such as `occupation:sjukskoterska-grundutbildad`) to also match up to five close occupations whose ads are most similar. Occupation groups, fields and excluded (`-`) occupations are not widened. Use it when an exact occupation finds too few ads. On /search, `tagFilters` names the added occupations (`similarTags`). |
| `keyword` (repeatable) | `keywords`? | `string[]` | Keyword keys: skills, tools and subjects an ad names. Repeat for several; an ad matches if it has any of them: `keyword=python&keyword=excel`. Use the `key` of an item in an ad's `keywords` or in GET /search/keywords. An unknown keyword matches nothing. |
| `tag` (repeatable) | `tags`? | `string[]` | Tag keys to filter on, in the filter grammar (see the notes): `tag=occupation:2221&tag=geo:0180` is nurses in Stockholm. |
| `excludeText` (repeatable) | `excludeTexts`? | `string[]` | Leave out ads whose headline or occupation contains this word (whole word, any case); a mention only in the ad's text doesn't count. Repeat for several, up to 10 words of up to 60 characters: `excludeText=.net&excludeText=java`. Don't put these words in `q` too. GET /tags?q= returns them as `reading.excludeWords`. |
| `deadlineAfter` | `deadlineAfter`? | `date` | Only ads with an application deadline on or after this date. Ads without a deadline always match. |
| `deadlineBefore` | `deadlineBefore`? | `date` | Only ads with an application deadline on or before this date. Ads without a deadline never match. |
| `publishedAfter` | `publishedAfter`? | `date` | Only ads published on or after this date. For fixed windows (today, last 3 days, last week, last month), the `published:` tags do the same, e.g. `tag=published:7-dagar`. |
| `bbox` | `bbox`? | `string` | An area of the map as `minLat,minLon,maxLat,maxLon` in WGS 84 (`59.30,18.00,59.36,18.12`): only ads whose job is at a spot inside it, or somewhere in a place (district, tätort, kommun …) that overlaps it. `areaMatch` says which places count. |
| `near` | `near`? | `string` | A point as `lat,lon` in WGS 84 (`59.3326,18.0649`): only ads whose job is at a spot within `withinKm` of it, or somewhere in a place that comes that close. `areaMatch` says which places count. |
| `withinKm` | `withinKm`? | `number` | How far from `near`, in km, 0.1–100; default 5. Only with `near`. |
| `areaMatch` | `areaMatch`? | `"inside" \| "overlap" \| "none"` | With `bbox` or `near`, which jobs we can only place in a place (a district, tätort or kommun) match: "inside" (default) when at least half the place lies in the area, so a map of Bromma shows Bromma's jobs but not every job somewhere in Stockholm; "overlap" when the place only touches it; "none" for jobs at a known spot only. |
| `mode` | `mode`? | `"hybrid" \| "fts" \| "vector"` | How `q` matches: "hybrid" (default) combines matching words with matching meaning; "fts" matches words only and is fastest; "vector" matches meaning only, and `total` is then null, so page with `hasMore`. Ignored without `q`. |
| `rerank` | `rerank`? | `boolean` | Re-order the best 150 matches with a slower, more precise relevance model; each row then carries `score` (0–1), which ranks within one search and does not compare across searches. Takes a few seconds. Paging only reaches as deep as those 150, which `hasMore` reports. Ignored without `q`. |
| `deepRerank` | `deepRerank`? | `boolean` | With `rerank`: re-order the best 400 matches instead of 150. Finds more of the best ads, but takes over twice as long. |
| `minScore` | `minScore`? | `number` | With `rerank`: leave out rows whose `score` is below this (0–1). The page then carries `belowMinScoreCount`, how many were left out; `total` still counts every match. Rows under about 0.02 are almost always unrelated. |
| `limit` | `limit`? | `number` | Page size, 1–100; default 20. |
| `offset` | `offset`? | `number` | Results to skip; default 0. |

**Response**

One page of search results. `total` is exact in `hybrid` (the default) and `fts` mode, and null in `vector` mode, which ranks every open ad.
With `rerank`, only the top results are reranked and paged (150, or 400 with `deepRerank`), so `hasMore` can turn false before `total` is reached; `total` still counts every match.

| Field | Type | Description |
|---|---|---|
| `total` | `number \| null` | How many results match in all. Null when the listing has no exact count (the type says when). |
| `hasMore` | `boolean` | Whether another page follows. Always given, with or without a total. |
| `limit` | `number` | The page size: the `limit` asked for, or the default. |
| `offset` | `number` | How many results come before this page. |
| `results` | [`JobAdSummary`](#jobadsummary)[] | The page's results. |
| `wordMatchCount`? | `number` | Only with `q` in hybrid mode: how many of `total` contain the query's words. The rest (at most 60) match only by meaning. |
| `belowMinScoreCount`? | `number` | Only with `minScore`: how many reranked ads scored below it and were left out. |
| `tagFilters` | [`TagFilterGroup`](#tagfiltergroup)[] | The `tag` filter in normalised form, one entry per `tag=` value: duplicates and redundant tags removed, replaced tags swapped for their replacement, keys in a fixed order. Build URLs and chips from it. Empty without `tag`. |

---

## GET /search/keywords

Reads /search's parameters, so a cloud describes the search it sits beside rather than its own reading of the same URL — minus `offset`, since the cloud describes the whole match, and with `limit` counting tags.

The keyword cloud of a search: the keywords over-represented among the ads `/search` would match for the same parameters, compared with every open ad (docs/ad-keywords.md).
Takes `/search`'s parameters except `offset`, which the cloud has no use for (it describes the whole match, not a page) and which is a 400 here like any unknown key; `limit` is how many keywords come back (default 30, max 100), not a page size.
Ranked by share × ln(lift), so a keyword has to be both common here and rarer elsewhere; empty when the match is so broad nothing stands out — no filters at all, or a query as wide as the corpus.
Was `/search/tags` until 2026-09-24, when tags became the tag layer's.

**Query parameters**

| Query key | Field | Type | Description |
|---|---|---|---|
| `q` | `q`? | `string` | Free text, matched against the ad's headline and description; how depends on `mode`. Each word also matches as a prefix, so a half-typed word works. |
| `includeSimilarOccupations` | `includeSimilarOccupations`? | `boolean` | Widen each occupation name in `tag` (an `occupation-name` key such as `occupation:sjukskoterska-grundutbildad`) to also match up to five close occupations whose ads are most similar. Occupation groups, fields and excluded (`-`) occupations are not widened. Use it when an exact occupation finds too few ads. On /search, `tagFilters` names the added occupations (`similarTags`). |
| `keyword` (repeatable) | `keywords`? | `string[]` | Keyword keys: skills, tools and subjects an ad names. Repeat for several; an ad matches if it has any of them: `keyword=python&keyword=excel`. Use the `key` of an item in an ad's `keywords` or in GET /search/keywords. An unknown keyword matches nothing. |
| `tag` (repeatable) | `tags`? | `string[]` | Tag keys to filter on, in the filter grammar (see the notes): `tag=occupation:2221&tag=geo:0180` is nurses in Stockholm. |
| `excludeText` (repeatable) | `excludeTexts`? | `string[]` | Leave out ads whose headline or occupation contains this word (whole word, any case); a mention only in the ad's text doesn't count. Repeat for several, up to 10 words of up to 60 characters: `excludeText=.net&excludeText=java`. Don't put these words in `q` too. GET /tags?q= returns them as `reading.excludeWords`. |
| `deadlineAfter` | `deadlineAfter`? | `date` | Only ads with an application deadline on or after this date. Ads without a deadline always match. |
| `deadlineBefore` | `deadlineBefore`? | `date` | Only ads with an application deadline on or before this date. Ads without a deadline never match. |
| `publishedAfter` | `publishedAfter`? | `date` | Only ads published on or after this date. For fixed windows (today, last 3 days, last week, last month), the `published:` tags do the same, e.g. `tag=published:7-dagar`. |
| `bbox` | `bbox`? | `string` | An area of the map as `minLat,minLon,maxLat,maxLon` in WGS 84 (`59.30,18.00,59.36,18.12`): only ads whose job is at a spot inside it, or somewhere in a place (district, tätort, kommun …) that overlaps it. `areaMatch` says which places count. |
| `near` | `near`? | `string` | A point as `lat,lon` in WGS 84 (`59.3326,18.0649`): only ads whose job is at a spot within `withinKm` of it, or somewhere in a place that comes that close. `areaMatch` says which places count. |
| `withinKm` | `withinKm`? | `number` | How far from `near`, in km, 0.1–100; default 5. Only with `near`. |
| `areaMatch` | `areaMatch`? | `"inside" \| "overlap" \| "none"` | With `bbox` or `near`, which jobs we can only place in a place (a district, tätort or kommun) match: "inside" (default) when at least half the place lies in the area, so a map of Bromma shows Bromma's jobs but not every job somewhere in Stockholm; "overlap" when the place only touches it; "none" for jobs at a known spot only. |
| `mode` | `mode`? | `"hybrid" \| "fts" \| "vector"` | How `q` matches: "hybrid" (default) combines matching words with matching meaning; "fts" matches words only and is fastest; "vector" matches meaning only, and `total` is then null, so page with `hasMore`. Ignored without `q`. |
| `limit` | `limit`? | `number` | How many keywords, 1–100; default 30. A top list, not a page. |

**Response**

The keywords that stand out in a search's matches compared with all open ads.
A top list, not a page: no `total`, `hasMore` or `offset`.

| Field | Type | Description |
|---|---|---|
| `matchedAdsCount` | `number` | Ads the search matched: the cloud describes these, and only these. |
| `limit` | `number` | How many keywords at most: the `limit` asked for (1–100, default 30). |
| `results` | [`KeywordCloudItem`](#keywordclouditem)[] | The keywords, highest score first. Empty when none stand out. |

---

## GET /search/tag-counts

Facet counts beside a search: the same parameters as /search, one facet counted over its match.

Facet counts for a search: how many of the ads `/search` would match for the same parameters carry each tag of one facet (docs/tags-model.md §5).
Takes `/search`'s parameters except `offset`, plus `facetKey`; `limit` is how many tags (default 100, max 1000).
400 for an unknown facet.
Unlisted tags (deprecated occupations) are left out.

**Query parameters**

| Query key | Field | Type | Description |
|---|---|---|---|
| `q` | `q`? | `string` | Free text, matched against the ad's headline and description; how depends on `mode`. Each word also matches as a prefix, so a half-typed word works. |
| `includeSimilarOccupations` | `includeSimilarOccupations`? | `boolean` | Widen each occupation name in `tag` (an `occupation-name` key such as `occupation:sjukskoterska-grundutbildad`) to also match up to five close occupations whose ads are most similar. Occupation groups, fields and excluded (`-`) occupations are not widened. Use it when an exact occupation finds too few ads. On /search, `tagFilters` names the added occupations (`similarTags`). |
| `keyword` (repeatable) | `keywords`? | `string[]` | Keyword keys: skills, tools and subjects an ad names. Repeat for several; an ad matches if it has any of them: `keyword=python&keyword=excel`. Use the `key` of an item in an ad's `keywords` or in GET /search/keywords. An unknown keyword matches nothing. |
| `tag` (repeatable) | `tags`? | `string[]` | Tag keys to filter on, in the filter grammar (see the notes): `tag=occupation:2221&tag=geo:0180` is nurses in Stockholm. |
| `excludeText` (repeatable) | `excludeTexts`? | `string[]` | Leave out ads whose headline or occupation contains this word (whole word, any case); a mention only in the ad's text doesn't count. Repeat for several, up to 10 words of up to 60 characters: `excludeText=.net&excludeText=java`. Don't put these words in `q` too. GET /tags?q= returns them as `reading.excludeWords`. |
| `deadlineAfter` | `deadlineAfter`? | `date` | Only ads with an application deadline on or after this date. Ads without a deadline always match. |
| `deadlineBefore` | `deadlineBefore`? | `date` | Only ads with an application deadline on or before this date. Ads without a deadline never match. |
| `publishedAfter` | `publishedAfter`? | `date` | Only ads published on or after this date. For fixed windows (today, last 3 days, last week, last month), the `published:` tags do the same, e.g. `tag=published:7-dagar`. |
| `bbox` | `bbox`? | `string` | An area of the map as `minLat,minLon,maxLat,maxLon` in WGS 84 (`59.30,18.00,59.36,18.12`): only ads whose job is at a spot inside it, or somewhere in a place (district, tätort, kommun …) that overlaps it. `areaMatch` says which places count. |
| `near` | `near`? | `string` | A point as `lat,lon` in WGS 84 (`59.3326,18.0649`): only ads whose job is at a spot within `withinKm` of it, or somewhere in a place that comes that close. `areaMatch` says which places count. |
| `withinKm` | `withinKm`? | `number` | How far from `near`, in km, 0.1–100; default 5. Only with `near`. |
| `areaMatch` | `areaMatch`? | `"inside" \| "overlap" \| "none"` | With `bbox` or `near`, which jobs we can only place in a place (a district, tätort or kommun) match: "inside" (default) when at least half the place lies in the area, so a map of Bromma shows Bromma's jobs but not every job somewhere in Stockholm; "overlap" when the place only touches it; "none" for jobs at a known spot only. |
| `mode` | `mode`? | `"hybrid" \| "fts" \| "vector"` | How `q` matches: "hybrid" (default) combines matching words with matching meaning; "fts" matches words only and is fastest; "vector" matches meaning only, and `total` is then null, so page with `hasMore`. Ignored without `q`. |
| `limit` | `limit`? | `number` | How many tags, 1–1000; default 100, the ones with most matched ads. A top list, not a page. |
| `facetKey` | `facetKey` | `string` | The facet to count: a facet key from GET /tag-facets, e.g. `municipality`. So that the other choices keep their counts, `tag` groups of this facet don't narrow the counts; for a place facet, no place group does. Excluded (`-`) groups still apply. So `matchedAdsCount` can be larger than /search's `total` for the same filters. |

**Response**

Facet counts over a search's match (GET /search/tag-counts).

| Field | Type | Description |
|---|---|---|
| `matchedAdsCount` | `number` | Ads the search matched, ignoring the `tag` group on the counted facet (see `results`). It can exceed /search's `total`. |
| `facetKey` | [`TagFacetKey`](#tagfacetkey) | The counted facet. |
| `limit` | `number` | How many tags at most: the `limit` asked for (1–1000, default 100). |
| `results` | `(TagItem & { adsCount: number })[]` | The facet's tags among the matched ads, most ads first, each with `adsCount`. A tag's count includes the ads of the tags under it. If exactly one positive `tag` group uses only this facet's tags, it is ignored for counting, so a UI can show the alternatives to a choice. All other groups, and negated groups, narrow the counts. The place facets (Land, Län, Kommun, …) count as one facet for this rule. |

---

## GET /map

docs/job-map.md: the data for a job map, /search's filters unpaged.

The data for a job map (docs/job-map.md): every job `/search` would match for the same parameters, unpaged, as rows of where it is — a `spot` (its workplace address or unit), an `area` (somewhere in a district, tätort, site or kommun, with a point inside the place to draw a label on) and, with `includeBases`, a `base` (the unit or office a job done elsewhere belongs to).
Takes `/search`'s parameters except `offset`, `limit` and the rerank ones; `bbox`, `near` and `areaMatch` work as on `/search`.
Ads with no place (an unspecified kommun) have no rows.

**Query parameters**

| Query key | Field | Type | Description |
|---|---|---|---|
| `q` | `q`? | `string` | Free text, matched against the ad's headline and description. How it matches and ranks depends on `mode`. Each word also matches as a prefix, so a half-typed word works. Without `q`, results are newest first. |
| `includeSimilarOccupations` | `includeSimilarOccupations`? | `boolean` | Widen each occupation name in `tag` (an `occupation-name` key such as `occupation:sjukskoterska-grundutbildad`) to also match up to five close occupations whose ads are most similar. Occupation groups, fields and excluded (`-`) occupations are not widened. Use it when an exact occupation finds too few ads. On /search, `tagFilters` names the added occupations (`similarTags`). |
| `keyword` (repeatable) | `keywords`? | `string[]` | Keyword keys: skills, tools and subjects an ad names. Repeat for several; an ad matches if it has any of them: `keyword=python&keyword=excel`. Use the `key` of an item in an ad's `keywords` or in GET /search/keywords. An unknown keyword matches nothing. |
| `tag` (repeatable) | `tags`? | `string[]` | Tag keys to filter on, in the filter grammar (see the notes): `tag=occupation:2221&tag=geo:0180` is nurses in Stockholm. |
| `excludeText` (repeatable) | `excludeTexts`? | `string[]` | Leave out ads whose headline or occupation contains this word (whole word, any case); a mention only in the ad's text doesn't count. Repeat for several, up to 10 words of up to 60 characters: `excludeText=.net&excludeText=java`. Don't put these words in `q` too. GET /tags?q= returns them as `reading.excludeWords`. |
| `deadlineAfter` | `deadlineAfter`? | `date` | Only ads with an application deadline on or after this date. Ads without a deadline always match. |
| `deadlineBefore` | `deadlineBefore`? | `date` | Only ads with an application deadline on or before this date. Ads without a deadline never match. |
| `publishedAfter` | `publishedAfter`? | `date` | Only ads published on or after this date. For fixed windows (today, last 3 days, last week, last month), the `published:` tags do the same, e.g. `tag=published:7-dagar`. |
| `bbox` | `bbox`? | `string` | An area of the map as `minLat,minLon,maxLat,maxLon` in WGS 84 (`59.30,18.00,59.36,18.12`): only ads whose job is at a spot inside it, or somewhere in a place (district, tätort, kommun …) that overlaps it. `areaMatch` says which places count. |
| `near` | `near`? | `string` | A point as `lat,lon` in WGS 84 (`59.3326,18.0649`): only ads whose job is at a spot within `withinKm` of it, or somewhere in a place that comes that close. `areaMatch` says which places count. |
| `withinKm` | `withinKm`? | `number` | How far from `near`, in km, 0.1–100; default 5. Only with `near`. |
| `areaMatch` | `areaMatch`? | `"inside" \| "overlap" \| "none"` | With `bbox` or `near`, which jobs we can only place in a place (a district, tätort or kommun) match: "inside" (default) when at least half the place lies in the area, so a map of Bromma shows Bromma's jobs but not every job somewhere in Stockholm; "overlap" when the place only touches it; "none" for jobs at a known spot only. |
| `mode` | `mode`? | `"hybrid" \| "fts" \| "vector"` | How `q` matches: "hybrid" (default) combines matching words with matching meaning; "fts" matches words only and is fastest; "vector" matches meaning only, and `total` is then null, so page with `hasMore`. Ignored without `q`. |
| `includeBases` | `includeBases`? | `boolean` | Also the units or offices that jobs done elsewhere belong to (`precision: "base"`): a home-care unit, a staffing office, a depot. |

**Response**

Where every job matching the filters is (GET /map), unpaged.

| Field | Type | Description |
|---|---|---|
| `total` | `number` | How many jobs the rows are for. |
| `results` | [`MapLocation`](#maplocation)[] | Every row of every matching job, by job. |

---

## GET /sitemap/jobs

For the website's sitemap.xml (sitemap.ts): whole lists, fetched about hourly, so they may be cached for ten minutes.

Every ad `/search` lists, as its id and when it last changed, in one unpaged answer sorted by id: for a sitemap.
Cached for ten minutes.

**Response**

Every open job ad, the ones GET /search lists, by id.

| Field | Type | Description |
|---|---|---|
| `results` | [`SitemapJob`](#sitemapjob)[] |  |

---

## GET /sitemap/employers

Every employer with at least one ad `/search` lists, as its organisation number and when the latest of those ads changed, in one unpaged answer sorted by organisation number: for a sitemap.
Cached for ten minutes.

**Response**

Every employer with at least one open job ad, by organisation number.

| Field | Type | Description |
|---|---|---|
| `results` | [`SitemapEmployer`](#sitemapemployer)[] |  |

---

## GET /tag-facets

The tag layer (docs/tags-model.md §5): what a client can filter on with tag=, and each tag in full.

The facet tree (docs/tags-model.md §2): the concepts a tag can be — Geografi → Land → Län → Kommun, Yrke, Anställning, Om arbetsgivaren, Tid — each with how many open ads carry a tag in it.
Small; fetch once and cache.
A facet with `retrieval: "search"` (Arbetsgivare) is too large to list: ask `/tags` with `q`.

**Response**

The whole facet tree: a small set to fetch once and cache, not a page.

| Field | Type | Description |
|---|---|---|
| `results` | [`TagFacet`](#tagfacet)[] | Every public facet, each after its parent. |
| `sections` | [`SuggestionSectionInfo`](#suggestionsectioninfo)[] | The suggestion sections, in the order a sectioned answer shows them. |

---

## GET /tags

Tags a client can filter on with `tag=`: a facet's top level (`facetKey`), a tag's children (`parentKey`), tags by label (`q`, across facets or within `facetKey`), or tags by key (`key=`).
400 without `facetKey`, `parentKey`, `q` or `key`, for an unknown facet, and for a `search` facet without `q` or `parentKey`.
Only public tags; retired ones only by key.

**Query parameters**

| Query key | Field | Type | Description |
|---|---|---|---|
| `facetKey` | `facetKey`? | `string` | Only this facet's tags, a facet key from GET /tag-facets such as `municipality`. Without `q` and `parentKey`, the facet's top level. |
| `parentKey` | `parentKey`? | `string` | Only the children of this tag key: walks a tree a level at a time. An unknown key is a 400. |
| `q` | `q`? | `string` | Typed text, 2 characters or more; required unless `facetKey`, `parentKey` or `key` is given. Matches tag labels, English labels and, for occupations, Arbetsförmedlingen's and SCB's job titles (`matchedAlias` says which), ignoring case and å/ä/ö. Send the text as typed: a trailing space marks the last word as complete. Two characters match only the start of a label; a complete word also matches the end of one (`utvecklare ` finds Mjukvaruutvecklare). Results come in the order to show them. A complete word of five letters or more that matches nothing may return close spellings instead, with `isSpellingGuess` true. Without `facetKey` or `parentKey`, tags with no open ads are left out, the suggestions complete the text's last words (`suggestionsFor`), and `reading` turns the rest into /search's filters (see the note "From typed text to filters"). |
| `key` (repeatable) | `keys`? | `string[]` | Look up tags by key, up to 50, returned in the order given. Unlisted tags (retired occupations, `isListed` false) are included; a key that is no tag is skipped without an error. Can't be combined with other parameters. |
| `flat` | `flat`? | `boolean` | With `facetKey` only: every tag in the facet, all levels, broadest first (`facetKey=published&flat=true`). |
| `sort` | `sort`? | `"openAds"` | `openAds`: order a `facetKey` or `parentKey` listing (without `q`) by open ads, most first, and leave out tags with none. Without it, the tree's own order. |
| `perFacet` | `perFacet`? | `number` | With `q`: at most this many tags from each facet (1 or more), so one large facet such as employers can't fill the list. Can't be combined with `offset`. |
| `sections` | `sections`? | `boolean` | With `q` and no `facetKey` or `parentKey`: group the suggestions into the sections GET /tag-facets lists (Yrke, Plats, Arbetsgivare, Övrigt), best-matching section first. Each section shows up to its `slots` rows and passes unused rows on to the next. In a section, a broad tag comes first with up to three narrower matches as `children` (Stockholms län, with Stockholm under it), except after a negation. A tag is shown once. `results` still holds every row as a flat list. Can't be combined with `offset` or `perFacet`. |
| `skipKey` (repeatable) | `skipKeys`? | `string[]` | With `q`: tag keys to leave out of the suggestions, such as those already chosen, so the next match takes their place. An unknown key is ignored. |
| `limit` | `limit`? | `number` | Page size, 1–100; default 20. With `sections`: the rows of the whole answer, children included; default 8, at most 30. |
| `offset` | `offset`? | `number` | Results to skip; default 0. |

**Response**

A page of tags (GET /tags).

| Field | Type | Description |
|---|---|---|
| `total` | `number` | How many tags match in all. |
| `hasMore` | `boolean` | Whether another page follows. |
| `limit` | `number` | The page size: the `limit` asked for, or the default. |
| `offset` | `number` | How many tags come before this page. |
| `results` | [`TagListItem`](#taglistitem)[] | The page's tags. |
| `reading`? | [`QueryReading`](#queryreading) | Only with `q` and no `facetKey` or `parentKey`: what the typed text means as /search parameters. Without a trailing space, the part from `suggestionsFor.start` on is still being typed and is left out. |
| `suggestionsFor`? | [`SuggestionTarget`](#suggestiontarget) \| `null` | Only with `q` and no `facetKey` or `parentKey`: the end of `q` that `results` would replace; for spelling guesses, the misspelt word. Null when nothing is suggested. |
| `sections`? | [`TagSuggestionSection`](#tagsuggestionsection)[] | Only with `sections=true`: the suggestions grouped in sections, best match first, empty sections left out. `results` then holds the same tags flat, each before its children. |

---

## GET /tags/:key

Returns one [`TagDetail`](#tagdetail) — its children and `replacedByKey` — or 404.
An unlisted tag (a deprecated occupation) is found here, so an old link still resolves.

**Path parameters**

| Param | Description |
|---|---|
| `key` | A tag key, e.g. `geo:0184`. |

---

## GET /places/:code

One kommun or län in full, the page a place tag leads to (TagDetail.entityPath).

Returns one [`PlaceDetail`](#placedetail) — a kommun or a län with its facts, the whole encyclopedia lead and its ad counts — or 404.
The page a place tag's `entityPath` points at.

**Path parameters**

| Param | Description |
|---|---|
| `code` | The kommun's 4-digit code (`0184`) or the län's 2-digit code (`01`), from Statistics Sweden (SCB), or its tag key (`geo:0184`). Other places, such as districts and localities, have no page here. |

---

## GET /jobs

Detail pages: one ad, or a batch (a saved-ads list, a comparison view) via repeated ids (id=A&id=B, same style as /search's employerOrgNumber=).
Serves closed ads too, unlike every listing route — see JobAdDetail's doc comment in job-ads.ts.

400 for no `id`, or more than 50.
Each result is a [`JobAdDetail`](#jobaddetail).

**Query parameters**

| Query key | Field | Type | Description |
|---|---|---|---|
| `id` (repeatable) | `ids` | `string[]` | Ad ids, 1–50; repeat the parameter: `id=A&id=B`. Repeats count once; an id that matches no ad comes back in `notFoundIds` instead of failing the request. |
| `detail` | `detail`? | `"card" \| "full"` | How the markdown answer shows each ad: "full" (default) is the whole ad, "card" its head, terms and text about the candidate. The JSON is always the full ad. |

**Response**

Several job ads in full (GET /jobs?id=…).

| Field | Type | Description |
|---|---|---|
| `results` | [`JobAdDetail`](#jobaddetail)[] | The ads in the order their ids were requested, duplicates collapsed. |
| `notFoundIds` | `string[]` | Requested ids with no job ad behind them. |

---

## GET /jobs/:id

Returns one [`JobAdDetail`](#jobaddetail), or 404.

**Path parameters**

| Param | Description |
|---|---|
| `id` | The ad's `id`. |

---

## GET /jobs/:id/similar

"More like this" for one ad: other currently-open ads ranked by similarity to it, narrowed by how close employer, occupation and place must be.
The ad itself may be closed.
404 when the ad doesn't exist or has no embedding — see findSimilarAds in similar-ads.ts.

**Path parameters**

| Param | Description |
|---|---|
| `id` | The `id` of the ad to find others like; it may be closed. |

**Query parameters**

| Query key | Field | Type | Description |
|---|---|---|---|
| `employerMatch` | `employerMatch`? | `"other" \| "same" \| "any"` | "other" (default): only other employers' ads. "same": only this employer's ads; add `occupationMatch=any` for all its jobs. "same" gives no results when the ad has no organization number. "any": any employer. |
| `occupationMatch` | `occupationMatch`? | `"group" \| "occupation" \| "field" \| "any"` | How close the occupation must be, narrowest first: "occupation" (the same occupation name), "group" (default; the same occupation group, a 4-digit SSYK code), "field" (the same occupation field, yrkesområde, e.g. Data/IT) or "any". If the ad's occupation name is retired, "occupation" falls back to "group". |
| `geoMatch` | `geoMatch`? | `"any" \| "municipality" \| "neighbours" \| "region"` | How near the place must be: "any" (default), "municipality" (the ad's kommun), "neighbours" (that kommun and the kommuner with much commuting to or from it; Gotland, which has none, uses its län) or "region" (the ad's län). Each ad counts by the one kommun Arbetsförmedlingen lists for it, not by other places it names. Add `minResults` to widen the area only when too few ads are found. |
| `tag` (repeatable) | `tags`? | `string[]` | Tag keys to filter on, in the filter grammar (see the notes): `tag=occupation:2221&tag=geo:0180` is nurses in Stockholm. |
| `deadlineAfter` | `deadlineAfter`? | `date` | Only ads with an application deadline on or after this date. Ads without a deadline always match. |
| `deadlineBefore` | `deadlineBefore`? | `date` | Only ads with an application deadline on or before this date. Ads without a deadline never match. |
| `publishedAfter` | `publishedAfter`? | `date` | Only ads published on or after this date. For fixed windows (today, last 3 days, last week, last month), the `published:` tags do the same, e.g. `tag=published:7-dagar`. |
| `minResults` | `minResults`? | `number` | The fewest results wanted, 1–12, at most `limit`. If fewer are found, the area widens one step at a time (kommun → with neighbours → län → anywhere), an exact occupation then to its group; with a `geoMatch` narrower than `any`, an occupation still too small widens to its field near the ad; last, with `employerMatch=other`, the ad's own employer's other ads are let in. When no step finds enough, the one that found most answers. `seed` says which levels were used. |
| `limit` | `limit`? | `number` | Page size, 1–12; default 12. |
| `offset` | `offset`? | `number` | Results to skip; default 0. |

**Response**

One page of ads similar to the seed ad, most similar first. `total` is always null, since every open ad is ranked; page with `hasMore`.
Near-duplicates (same employer and headline) are not merged.

| Field | Type | Description |
|---|---|---|
| `total` | `number \| null` | How many results match in all. Null when the listing has no exact count (the type says when). |
| `hasMore` | `boolean` | Whether another page follows. Always given, with or without a total. |
| `limit` | `number` | The page size: the `limit` asked for, or the default. |
| `offset` | `number` | How many results come before this page. |
| `results` | [`JobAdSummary`](#jobadsummary)[] | The page's results. |
| `seed` | [`SimilarAdsSeed`](#similaradsseed) | The ad the results are similar to, and how it was matched. |

---

## GET /employers

**Query parameters**

| Query key | Field | Type | Description |
|---|---|---|---|
| `q` | `q`? | `string` | Part of the employer's name, any case. |
| `tag` (repeatable) | `tags`? | `string[]` | Employer and place tag keys, in the filter grammar (see the notes). An employer key (sector, employer type, industry, SNI industry code, or one employer) matches the employer's own tags: `tag=employer:region`. A place key matches employers with a workplace, an office or the registered address there or below: `tag=geo:0180`, `tag=geo:0180S-kista~grannar`. Any other key is a 400. |
| `near` | `near`? | `string` | A point as `lat,lon` in WGS 84 (`59.3326,18.0649`): only employers with an address within `withinKm` of it, nearest first, each with `nearestAddress`. |
| `withinKm` | `withinKm`? | `number` | How far from `near`, in km, 0.1–50; default 5. Only with `near`. |
| `limit` | `limit`? | `number` | Page size, 1–100; default 20. |
| `offset` | `offset`? | `number` | Results to skip; default 0. |

**Response**

One page of employers: nearest first with `near`, otherwise most open ads first. `total` is the exact number matching the filters.

| Field | Type | Description |
|---|---|---|
| `total` | `number \| null` | How many results match in all. Null when the listing has no exact count (the type says when). |
| `hasMore` | `boolean` | Whether another page follows. Always given, with or without a total. |
| `limit` | `number` | The page size: the `limit` asked for, or the default. |
| `offset` | `number` | How many results come before this page. |
| `results` | [`EmployerSummary`](#employersummary)[] | The page's results. |

---

## GET /employers/:organizationNumber

One employer's page: hiring aggregates and the SCB/Bolagsverket register values (industry, legal form, ongoing konkurs, …).
404 when the organisation number isn't in `organizations`.

**Path parameters**

| Param | Description |
|---|---|
| `organizationNumber` | The employer's Swedish organization number, 10 digits without a hyphen (`2321000255`), as in an employer's `organizationNumber` or an ad's `employerOrgNumber`. |

**Response**

One employer in full (GET /employers/:organizationNumber): hiring counts, register data, tags, addresses, units and, for a kommun or a regional authority, its place.

| Field | Type | Description |
|---|---|---|
| `organizationNumber` | `string` | The Swedish organisation number, 10 digits without a hyphen; the `:organizationNumber` of GET /employers/:organizationNumber. |
| `tagKey` | `string \| null` | The employer's tag key for `tag=`. Use it as given; never build it from the number. Null briefly for a newly added employer. |
| `name` | `string \| null` | The employer's name in normal case: Bolagsverket's registered name, else SCB's, else the name its ads use. |
| `website` | `string \| null` | The website its ads give, often a jobs page. |
| `totalAdsCount` | `number` | All its ads, open and closed. |
| `openAdsCount` | `number` | Its open ads with a link to apply: the ads `/search?tag=<tagKey>` lists. |
| `distinctHeadlinesCount` | `number` | How many different headlines its ads have, open and closed. |
| `municipalitiesCount` | `number` | How many municipalities it has advertised in. |
| `isLikelyStaffingAgency` | `boolean` | True when the employer posts many near-identical ads: at least 20 ads, and on average five or more per distinct headline. Its ad count then overstates its hiring. Not a test of whether it is a staffing agency; the `employer:bemanning-rekrytering` tag is. |
| `primaryOccupationFieldLabel` | `string \| null` | The AF occupation field most of its ads are in, open and closed. Null when none of its ads has one. |
| `employerCategory` | [`EmployerCategoryRef`](#employercategoryref) \| `null` | The employer's most specific category, with the path down to it. Each step has its `tagKey`. Null when the employer has no category. |
| `avatarInitials` | `string \| null` | Initials to show in place of a logo, from the employer's name. Null, like `avatarColor`, when the employer's name or number is missing. |
| `avatarColor` | `string \| null` | Background colour for `avatarInitials`, as `#RRGGBB`. Null when `avatarInitials` is. |
| `nearestAddress`? | [`EmployerNearestAddress`](#employernearestaddress) | Only with `near`: the employer's address nearest the point. |
| `organization` | [`EmployerOrganization`](#employerorganization) \| `null` | What Bolagsverket's and SCB's registers say about the employer. Null when neither knows its organisation number. |
| `place` | [`EmployerPlace`](#employerplace) \| `null` | For an employer that is a kommun or a regional authority: the kommun or län it governs, with its facts. Null for every other employer. |
| `addresses` | [`EmployerAddress`](#employeraddress)[] | The employer's addresses, at most 50: the one with most open ads first, then most ads in all. `addressesCount` is the full count. |
| `addressesCount` | `number` | How many addresses the employer has. |
| `units` | [`EmployerUnit`](#employerunit)[] | The employer's units in SCB's register of workplaces, at most 50: the one with most open ads first, then the largest. `unitsCount` is the full count. |
| `unitsCount` | `number` | How many units the employer has in SCB's register. |
| `alsoKnownAs` | `string[]` | Other names the employer goes by in its own job ads, most used first: a brand, a chain or shop name, a former name (e.g. "Nordic Wellness" for Sportlife M W AB). Empty when there are none. |
| `tags` | [`JobAdTagItem`](#jobadtagitem)[] | The employer's tags with all their ancestors, in facet order: sector, employer type, industry, SNI code and the employer itself. Each `key` works in /search's `tag`. |

---

## GET /occupations/:conceptId

One node: label, SSYK and SCB codes, AF's definition, SCB's description, its path from the yrkesområde, its fields and its children (a field's: its occupations).

`:conceptId` is the node's AF concept id or its tag key (`occupation:2512`, `occupation:sjukskoterska-grundutbildad`), read through the tag, never parsed.
404 for anything else, an occupation collection's key included.

**Path parameters**

| Param | Description |
|---|---|
| `conceptId` | An occupation's concept id, Arbetsförmedlingen's taxonomy id (`apaJ_2ja_LuF`, as in an ad's `occupationConceptId`), or its `occupation:` tag key (`occupation:2221`). Works at every level, from occupation field to occupation name. |

**Query parameters**

| Query key | Field | Type | Description |
|---|---|---|---|
| `includeEmpty` | `includeEmpty`? | `boolean` | Also list `children` with no open ad, which are left out by default. |

**Response**

One node of the occupation tree in full (GET /occupations/:conceptId): OccupationNode's fields, plus its definitions, path, children and similar occupations.

| Field | Type | Description |
|---|---|---|
| `tagKey` | `string \| null` | The tag key for `tag=`. Use it as given; never build it from codes. Null briefly for a newly added occupation. |
| `conceptId` | `string` | AF's concept id. A job ad carries it as `occupationConceptId` (an occupation), `occupationGroupConceptId` (level 4) or `occupationFieldConceptId` (a field). |
| `kind` | [`OccupationKind`](#occupationkind) | What the node is. A field has no level and no parent. |
| `level` | `number \| null` | The SSYK 2012 level, 1 (broadest) to 4 (occupation group), or 5 for an occupation. Null on a field. |
| `parentConceptId` | `string \| null` | The concept id of the node above. Null on level 1 and on a field. |
| `ssykCode` | `string \| null` | The SSYK 2012 code as AF uses it on levels 1–4, e.g. "532", "5321" or "1250". Null on occupations and fields. |
| `label` | `string` | The name to show. |
| `scbCode` | `string \| null` | The SSYK code to look the node up by in SCB's statistics: usually its own code, three digits for a level-4 group that SCB counts only at level 3. Null on occupations and fields. |
| `scbMatch` | [`ScbMatch`](#scbmatch) \| `null` | How `scbCode` relates to AF's code. Null on occupations and fields. |
| `isDeprecated` | `boolean` | True for an occupation AF has deprecated; older ads still carry it. |
| `fieldConceptId` | `string \| null` | The concept id of the AF occupation field this level-4 group or occupation belongs to. Null otherwise. |
| `openAdsCount` | `number` | Open ads: the ads `/search?tag=<tagKey>` lists. A group's count can differ from the sum of its occupations', since an ad can be filed under another group than its occupation's. |
| `replacedByConceptIds` | `string[]` | For a deprecated occupation, the concept ids AF replaced it with. Empty otherwise. |
| `replacedBy` | [`OccupationPathItem`](#occupationpathitem)[] | The replacements that are in the occupation tree, with their tags and labels. |
| `afDefinition` | `string \| null` | AF's definition, from its taxonomy. Null when AF has none or it only repeats the label. |
| `scbDescription` | `string[]` | SCB's description of `scbCode`: one paragraph at level 4, a list of tasks on levels 1–3. Empty on occupations and fields. |
| `path` | [`OccupationPathItem`](#occupationpathitem)[] | The path from SSYK level 1 down to this node. For a field, the field alone. |
| `field` | [`OccupationPathItem`](#occupationpathitem) \| `null` | The AF occupation field of a level-4 group or an occupation. Null on other nodes. |
| `children` | [`OccupationNode`](#occupationnode)[] | The node's direct children; for a field, its occupations. Children without open ads are left out unless `includeEmpty`. |
| `similar` | [`SimilarOccupation`](#similaroccupation)[] | Occupations whose ads read most like this one's, nearest first. Empty on other nodes than occupations, and for an occupation with few ads. |

---

# Shared types

---

## TagDetail

One tag in full (GET /tags/:key).

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The tag's key, e.g. `geo:0184`, `occupation:2221`, `employer:5567941173`. Pass it to `tag=`. |
| `code` | `string` | The tag's stable code within its facet, e.g. `driving-license-required`, `permanent`, `0184` (a kommun) or an organisation number. Compare against this, not the key. |
| `label` | `string` | The tag's name. |
| `facetKey` | [`TagFacetKey`](#tagfacetkey) | The tag's facet. |
| `parentKey` | `string \| null` | The key of the nearest visible ancestor. Null for a root. |
| `depth` | `number` | 0 for a root. Not comparable across facets: Sverige and Norge are both Land, at depths 0 and 1. |
| `levels` | [`TagLevel`](#taglevel)[] | The tag's visible ancestors, broadest first, then the tag itself last: breadcrumbs. |
| `openAdsCount` | `number` | Open ads with this tag or any tag under it. |
| `isListed` | `boolean` | False for a retired tag, such as a deprecated occupation: still on its ads and usable in `tag=`, but not offered in lists. |
| `replacedByKey` | `string \| null` | The one tag that replaced this one, when there is exactly one. Null otherwise. |
| `entityPath` | `string \| null` | The route that describes what the tag stands for, e.g. `/places/0184`, `/occupations/<conceptId>` or `/employers/<organizationNumber>`. Null for tags without one, such as dates and work conditions. |
| `children` | [`TagItem`](#tagitem)[] | Its listed children, in order. Empty when its children are in a `search` facet (Arbetsgivare); page through those with GET /tags?parentKey=<key>. |
| `withNeighboursKey` | `string \| null` | For a place with neighbours: the key that matches the place and the places around it (it ends in `~grannar`). Use it as given. Null for other tags and for places without neighbours (Gotland, sites). |

---

## PlaceDetail

One kommun or län in full (GET /places/:code).
Filter on it with `tagKey`.

| Field | Type | Description |
|---|---|---|
| `code` | `string` | SCB's code: four digits for a kommun, two for a län. |
| `kind` | [`PlaceKind`](#placekind) | What the place is. |
| `name` | `string` | The place's name, e.g. "Solna" or "Stockholms län". |
| `tagKey` | `string \| null` | The tag key for `tag=`. Use it as given; never build it from `code`. Null briefly for a newly added place. |
| `regionCode` | `string \| null` | The code of the län a kommun is in. Null for a län. |
| `regionName` | `string \| null` | The name of the län a kommun is in. Null for a län. |
| `regionTagKey` | `string \| null` | The län's tag key. Null for a län. |
| `openAdsCount` | `number` | Open ads carrying the place's tag: ads filed under the place or naming it as a work place; for a län, those of all its municipalities. |
| `totalAdsCount` | `number \| null` | All ads ever filed under the kommun, open and closed. Null for a län. |
| `distinctEmployersCount` | `number \| null` | Employers with an ad filed under the kommun. Null for a län. |
| `primaryOccupationFieldLabel` | `string \| null` | The AF occupation field most of the kommun's ads are in. Null for a län. |
| `facts` | [`PlaceFacts`](#placefacts) \| `null` | The place's population, area, seat, website, founding year and coat of arms. Null when there are none. |
| `encyclopedia` | [`PlaceEncyclopedia`](#placeencyclopedia) \| `null` | The lead of the place's Wikipedia article. Null when there is none. |

---

## JobAdDetail

One job ad in full: most of JobAdSummary's fields under the same names, plus the whole text in sections, employment terms, requirements and how to apply.
Closed ads are served too (`isOpen` is false).
A few removed ads were never seen open and hold little more than an id and `removedAt`.

| Field | Type | Description |
|---|---|---|
| `id` | `string` | The ad's id, the same as on Platsbanken. |
| `headline` | `string \| null` | The ad's own headline, as the employer wrote it. Null for a removed ad that was never seen open. |
| `jobTitle` | `string \| null` | A clean job title written from the ad by a language model, e.g. "Legitimerad optiker". Show "{jobTitle} hos {workplaceName}" in place of `headline` when it is set. Null until written (open ads get one within a day) and on most closed ads. |
| `workplaceName` | `string \| null` | The workplace's name as people know it, without legal form: the name in the ad's text, else Arbetsförmedlingen's workplace field, else the employer. Often not `employerName`: "Niemis Optik", not "Binokel AB". |
| `summary` | `string \| null` | One or two sentences on what the job is and what it asks for. It leaves out the title, workplace, place and employment terms, so show those beside it. Null when `jobTitle` is. |
| `isOpen` | `boolean` | Whether the ad can still be applied to: not removed, and no deadline or a future one. |
| `publishedAt` | `Date \| null` | When AF published the ad. |
| `deadlineAt` | `Date \| null` | When applications close: the end of the last day to apply. Null when the ad names no last day. |
| `removedAt` | `Date \| null` | When AF removed the ad. Null while it has not. |
| `webpageUrl` | `string \| null` | The ad on Platsbanken. |
| `logoUrl` | `string \| null` | The employer's logo from AF. Null when the ad has none. |
| `sections` | [`JobAdSection`](#jobadsection)[] | The ad's whole text, as sections in reading order. Render these to show the ad: the detail carries no other copy of the text. Empty when the ad has no text. |
| `keywords` | [`JobAdKeyword`](#jobadkeyword)[] | Keywords for what the ad asks for, uses and is about, taken from its `about_candidate` sections, highest `weight` first. Group them on `kind`. Empty when the ad has no such section or no keyword matched; an empty list does not mean the ad asks for nothing. |
| `tags` | [`JobAdTagItem`](#jobadtagitem)[] | The ad's tags with all their ancestors, in facet order (as GET /tag-facets lists the facets). `isPrimary` marks the tag per facet that a card shows. A closed ad keeps the tags it had when it closed, without the Tid facets (deadline, published). Ads that closed before late September 2026 have no tags. |
| `language` | `string \| null` | The language the ad is written in, as an ISO 639-3 code (`swe`, `eng`); `und` when it cannot be told. |
| `employerName` | `string \| null` | The employer's name as the ad gives it. |
| `employerOrgNumber` | `string \| null` | The employer's organisation number (EmployerSummary.organizationNumber). Null when the ad has none. |
| `employerWorkplace` | `string \| null` | The workplace name AF's ad gives, e.g. a school or clinic run by a differently named employer. `workplaceName` is the cleaned name to show. |
| `employer` | [`EmployerWithOrganization`](#employerwithorganization) \| `null` | The employer's hiring counts and register data. GET /employers/:organizationNumber adds its tags, addresses and place. Null when the ad has no organisation number. |
| `employerMetrics` | [`EmployerMetrics`](#employermetrics) \| `null` | The employer's ads, vacancies and län in the last 12 months. Null when the ad has no employer, or the employer is newer than the last count. |
| `avatarInitials` | `string \| null` | Initials to show in place of a logo, from the employer's name. Null, like `avatarColor`, when the employer's name or number is missing. |
| `avatarColor` | `string \| null` | Background colour for `avatarInitials`, as `#RRGGBB`. Null when `avatarInitials` is. |
| `occupationConceptId` | `string \| null` | AF's concept id of the ad's occupation; its tag is in `tags`. Null when the occupation is not in the occupation tree. |
| `occupationLabel` | `string \| null` | AF's name for the ad's occupation. |
| `occupationGroupConceptId` | `string \| null` | AF's concept id of the occupation group (SSYK level 4) the ad's occupation belongs to; its tag is in `tags`. Null when the ad has no group. |
| `occupationGroupLabel` | `string \| null` | The occupation group's current name. |
| `occupationFieldConceptId` | `string \| null` | AF's concept id of the occupation field the ad's occupation group belongs to. Null when the group has none. |
| `occupationFieldLabel` | `string \| null` | The occupation field's current name. |
| `ssykCode` | `string \| null` | The SSYK 2012 code of the ad's occupation group. Null when the ad has no group. |
| `occupationPath` | [`OccupationPathItem`](#occupationpathitem)[] | The ad's occupation in the occupation tree, broadest first: SSYK levels 1–4, then the occupation itself. Each item's `conceptId` works with GET /occupations/:conceptId. Empty when the ad's group is not in the tree; ends at the group when the occupation is not. |
| `employmentTypeCode` | [`EmploymentTypeCode`](#employmenttypecode) \| `null` | The employment type as a fixed code. Null when the ad does not say. |
| `employmentTypeLabel` | `string \| null` | AF's Swedish label for `employmentTypeCode`. |
| `durationCode` | [`DurationCode`](#durationcode) \| `null` | How long the employment lasts, as a fixed code. Null when the ad does not say. |
| `durationLabel` | `string \| null` | AF's Swedish label for `durationCode`. |
| `workingHoursTypeCode` | [`WorkingHoursTypeCode`](#workinghourstypecode) \| `null` | Full or part time, as a fixed code. Null when the ad does not say. |
| `workingHoursTypeLabel` | `string \| null` | AF's Swedish label for `workingHoursTypeCode`. |
| `scopeOfWorkMin` | `number \| null` | The lowest share of full time, in percent, e.g. 80. |
| `scopeOfWorkMax` | `number \| null` | The highest share of full time, in percent, e.g. 100. |
| `salaryTypeCode` | [`SalaryTypeCode`](#salarytypecode) \| `null` | How the salary is set, as a fixed code. Null when the ad does not say. |
| `salaryTypeLabel` | `string \| null` | AF's Swedish label for `salaryTypeCode`. |
| `salaryDescription` | `string \| null` | Free-text salary description, when the employer gave one. |
| `vacanciesCount` | `number \| null` | How many people the ad hires. |
| `municipalityCode` | `string \| null` | The four-digit code of the kommun AF files the ad under, e.g. "1480". A kommun filter also matches the kommuner in `workPlaces`. |
| `municipalityName` | `string \| null` | The name of the kommun AF files the ad under. |
| `regionCode` | `string \| null` | The two-digit code of that kommun's län, e.g. "14". |
| `regionName` | `string \| null` | The län's name. |
| `countryName` | `string \| null` | The country the ad is in, e.g. "Sverige". |
| `workPlaces` | [`JobAdWorkPlace`](#jobadworkplace)[] | Places the work is at beyond AF's kommun, read from the workplace address, the headline and the text: a district, locality, site or another kommun. Empty when the ad names no more than AF's kommun. Each item's `tag` works in `tag=`. |
| `unit` | [`JobAdUnit`](#jobadunit) \| `null` | The unit of the employer the job belongs to, from SCB's register. Null when it is not known well enough, and for staffing, home care and work for a client. |
| `pay` | [`JobAdPay`](#jobadpay) \| `null` | What the occupation pays according to SCB's wage statistics, for the ad's region and sector where SCB has it. Null when the ad has no occupation group SCB covers. |
| `employerHistory` | [`EmployerHistory`](#employerhistory) \| `null` | What the employer's own ads say about how it hires. Null when the ad's employer is not in the company register. |
| `employmentExplained` | [`EmploymentExplained`](#employmentexplained) \| `null` | What the ad's employment means, written with AI from its employment type, duration and staffing tags. Null when none applies. |
| `mustHave` | [`JobAdRequirements`](#jobadrequirements) | AF's requirements that the ad marks as required. |
| `niceToHave` | [`JobAdRequirements`](#jobadrequirements) | AF's requirements that the ad marks as a merit. |
| `experienceRequired` | `boolean \| null` | Whether AF marks experience as required. Null when not stated. |
| `drivingLicenseRequired` | `boolean \| null` | Whether AF marks a driving licence as required. Null when not stated. `drivingLicenses` lists the classes when the ad names them. |
| `drivingLicenses` | `string[]` | Licence classes asked for, as AF labels them. |
| `ownCarRequired` | `boolean \| null` | Whether the ad requires access to a car of one's own. Null when not stated. |
| `application` | [`JobAdApplication`](#jobadapplication) | How to apply. |
| `contacts` | [`JobAdContact`](#jobadcontact)[] | Contact people named in the ad. Empty when it names none, and for ads AF has removed or that come from its historical archive. |

---

## RouteDescription

One route: its path, parameters, an example request and its response type.

| Field | Type | Description |
|---|---|---|
| `method` | `string` | The HTTP method. |
| `path` | `string` | The path, with `:name` for each path parameter. |
| `summary` | `string \| null` | What the route returns, in one line. |
| `pathParams` | [`PathParamDescription`](#pathparamdescription)[] | What each `:name` in `path` takes, in path order. |
| `params` | [`ParamDescription`](#paramdescription)[] | Every query parameter the route takes. Any other is a 400. |
| `example` | `string \| null` | A working request, relative to `baseUrl`. |
| `response` | `string \| null` | The response's type, an entry in `types`. Null for / and /health. |

---

## TypeDescription

One response type.

| Field | Type | Description |
|---|---|---|
| `description` | `string \| null` | What the type is. Null when it has no description. |
| `fields`? | [`FieldDescription`](#fielddescription)[] | The fields of an object type, in the order a response carries them. Absent on a union type, which has `declaration` instead. |
| `declaration`? | `string` | A union type written out, e.g. `export type SectionPart = …`. |

---

## JobAdSummary

One job ad as a listing row: enough for a card.
GET /jobs/:id has the ad in full.

| Field | Type | Description |
|---|---|---|
| `id` | `string` | The ad's id, the same as on Platsbanken. |
| `headline` | `string \| null` | The ad's own headline, as the employer wrote it. Null for a removed ad that was never seen open. |
| `employerName` | `string \| null` | The employer's name as the ad gives it. |
| `employerOrgNumber` | `string \| null` | The employer's organisation number (EmployerSummary.organizationNumber). Null when the ad has none. |
| `municipalityName` | `string \| null` | The name of the kommun AF files the ad under. |
| `occupationLabel` | `string \| null` | AF's name for the ad's occupation. |
| `occupationConceptId` | `string \| null` | Arbetsförmedlingen's concept id of the ad's occupation; its tag is in `tags`. Null when the occupation is not in the occupation tree. |
| `language` | `string \| null` | The language the ad is written in, as an ISO 639-3 code (`swe`, `eng`); `und` when it cannot be told. |
| `publishedAt` | `Date \| null` | When AF published the ad. |
| `deadlineAt` | `Date \| null` | When applications close: the end of the last day to apply. Null when the ad names no last day. |
| `webpageUrl` | `string \| null` | The ad on Platsbanken. |
| `logoUrl` | `string \| null` | The employer's logo from AF. Null when the ad has none. |
| `jobTitle` | `string \| null` | A clean job title written from the ad by a language model, e.g. "Legitimerad optiker". Show "{jobTitle} hos {workplaceName}" in place of `headline` when it is set. Null until written (open ads get one within a day) and on most closed ads. |
| `workplaceName` | `string \| null` | The workplace's name as people know it, without legal form: the name in the ad's text, else Arbetsförmedlingen's workplace field, else the employer. Often not `employerName`: "Niemis Optik", not "Binokel AB". |
| `summary` | `string \| null` | One or two sentences on what the job is and what it asks for. It leaves out the title, workplace, place and employment terms, so show those beside it. Null when `jobTitle` is. |
| `descriptionSnippet` | `string \| null` | The first 200 characters of the ad's text as running text, for a preview. Null for an ad without text. `GET /jobs/:id` has the whole text. |
| `employerMetrics` | [`EmployerMetrics`](#employermetrics) \| `null` | The employer's ads, vacancies and län in the last 12 months. Null when the ad has no employer, or the employer is newer than the last count. |
| `employerCategory` | [`EmployerCategoryRef`](#employercategoryref) \| `null` | The employer's most specific category, with the path down to it. Each step has its `tagKey`. Null when the ad has no organisation number or the employer has no category. |
| `avatarInitials` | `string \| null` | Initials to show in place of a logo, from the employer's name. Null, like `avatarColor`, when the employer's name or number is missing. |
| `avatarColor` | `string \| null` | Background colour for `avatarInitials`, as `#RRGGBB`. Null when `avatarInitials` is. |
| `tags` | [`CardTag`](#cardtag)[] | The tags a card shows, in facet order: per facet the ad's primary tag (JobAdTagItem.isPrimary), or, in facets without one such as Körkort, its direct tags. GET /jobs/:id has all tags, ancestors included. |
| `score`? | `number` | Only with `rerank`: how relevant the reranker judged the ad to the query, 0–1. It ranks within one search, not across searches. |

---

## TagFilterGroup

One normalised `tag=` value.
Sent back unchanged, it matches the same ads.
Put `value` in the URL and show `tags` as chips.

| Field | Type | Description |
|---|---|---|
| `value` | `string` | The `tag=` value to put in a URL. |
| `isNegated` | `boolean` | True for an excluding group (`tag=-…`). |
| `tags` | [`TagChoice`](#tagchoice)[] | The group's tags, for chips. |
| `similarTags`? | [`TagChoice`](#tagchoice)[] | Only with `includeSimilarOccupations`: the occupations this group was widened with, whose ads it also matches. |

---

## KeywordCloudItem

A keyword in a search's cloud, with how much it stands out among the ads the search matched.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | A stable key; it works in `/search?keyword=`. |
| `label` | `string` | The keyword as it reads. |
| `kind` | [`KeywordKind`](#keywordkind) | What the keyword is. Group on this rather than showing one flat list. |
| `adsCount` | `number` | Matched ads with the keyword. |
| `share` | `number` | `adsCount` / `matchedAdsCount`: how common the keyword is in the matches. |
| `lift` | `number` | `share` / the keyword's share among all open ads: how much more common it is in the matches. |

---

## TagFacetKey

A facet's key, e.g. `municipality`.
GET /tag-facets lists every facet.

```ts
export type TagFacetKey =
  | "geography"
  | "country"
  | "region"
  | "municipality"
  | "district"
  | "locality"
  | "site"
  | "area"
  | "occupation"
  | "occupation-field"
  | "occupation-group"
  | "occupation-name"
  | "occupation-collection"
  | "occupation-search"
  | "ssyk"
  | "employment"
  | "employment-type"
  | "duration"
  | "working-hours"
  | "part-time"
  | "salary-type"
  | "support-form"
  | "job-type"
  | "schedule"
  | "thesis"
  | "benefits"
  | "language"
  | "employer-root"
  | "sector"
  | "employer-type"
  | "brand"
  | "for-client"
  | "industry"
  | "sni"
  | "employer"
  | "requirements"
  | "driving-license"
  | "experience"
  | "language-requirement"
  | "license"
  | "certification"
  | "background-check"
  | "workplace"
  | "pedagogy"
  | "school-form"
  | "workplace-type"
  | "care-specialty"
  | "remote-work"
  | "time"
  | "deadline"
  | "published";
```

---

## TagItem

A tag as GET /tags lists it.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The tag's key, e.g. `geo:0184`, `occupation:2221`, `employer:5567941173`. Pass it to `tag=`. |
| `code` | `string` | The tag's stable code within its facet, e.g. `driving-license-required`, `permanent`, `0184` (a kommun) or an organisation number. Compare against this, not the key. |
| `label` | `string` | The tag's name. |
| `facetKey` | [`TagFacetKey`](#tagfacetkey) | The tag's facet. |
| `parentKey` | `string \| null` | The key of the nearest visible ancestor. Null for a root. |
| `depth` | `number` | 0 for a root. Not comparable across facets: Sverige and Norge are both Land, at depths 0 and 1. |
| `levels` | [`TagLevel`](#taglevel)[] | The tag's visible ancestors, broadest first, then the tag itself last: breadcrumbs. |
| `openAdsCount` | `number` | Open ads with this tag or any tag under it. |

---

## MapLocation

One place a job is, on GET /map.
A job has one row per area, at most one spot, and its bases when asked.

| Field | Type | Description |
|---|---|---|
| `jobAdId` | `string` | The job ad's id (JobAdDetail.id). |
| `precision` | [`LocationPrecision`](#locationprecision) | How exact the row is. |
| `lat` | `number \| null` | Latitude: the spot's or base's, or for an area a point inside it to draw a label on — never where the job is. Null for an area without a geometry (a country). |
| `lon` | `number \| null` | Longitude, as `lat`. |
| `placeTagKey` | `string \| null` | The place the row is in — the area itself, or the finest place a spot lies in — as a tag key for `tag=`. Null when the place has no listed tag. |
| `workMode` | [`WorkMode`](#workmode) | How the job is done. |
| `weight` | `number` | For counting jobs by place: 1, or 1/n for a job at either of n places. |

---

## SitemapJob

An open job ad, on GET /sitemap/jobs.

| Field | Type | Description |
|---|---|---|
| `id` | `string` | The ad's id, as in GET /jobs/:id. |
| `updatedAt` | `Date` | When the ad last changed: Arbetsförmedlingen's last edit, or its publication if that is later. |

---

## SitemapEmployer

An employer with at least one open job ad, on GET /sitemap/employers.

| Field | Type | Description |
|---|---|---|
| `organizationNumber` | `string` | The Swedish organisation number, 10 digits without a hyphen; the `:organizationNumber` of GET /employers/:organizationNumber. |
| `updatedAt` | `Date` | When the latest of its open ads last changed. |

---

## TagFacet

A facet: a concept a tag can be (Kommun, Yrkesbenämning, Sista ansökningsdag), in the facet tree.

| Field | Type | Description |
|---|---|---|
| `key` | [`TagFacetKey`](#tagfacetkey) | The facet's key. |
| `parentKey` | [`TagFacetKey`](#tagfacetkey) \| `null` | The facet above this one. Null for a root facet. A tag's parent can be in a higher facet than this: an Arbetsplats tag can sit directly under a Kommun. |
| `label` | `string` | The facet's name. |
| `description` | `string \| null` | What the facet is, in Swedish. Null when there is no description. |
| `isExclusive` | `boolean` | True when an ad has at most one direct tag in this facet (Yrkesbenämning); false when it can have several (Kommun). |
| `isVolatile` | `boolean` | True when the tags depend on today's date (the Tid facets): responses with them go stale at midnight Swedish time. |
| `retrieval` | [`TagFacetRetrieval`](#tagfacetretrieval) | How to get the facet's tags. |
| `position` | `number` | Order among its siblings. |
| `openAdsCount` | `number` | Open ads with a tag in this facet, or for a root, in any facet under it. |
| `section` | `string` | The suggestion section for this facet's tags: `occupation`, `place`, `employer` or `other`. Two picks in one section usually mean either one (OR). |

---

## SuggestionSectionInfo

A section a search box's suggestions are grouped in (GET /tags?sections=true).

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The section's key: `occupation`, `place`, `employer` or `other`. |
| `label` | `string` | The section's name in Swedish. |
| `labelEn` | `string \| null` | The section's name in English. Null when there is none. |
| `slots` | `number` | Rows it gets in a sectioned answer, a child counting as a row; a section with fewer matches gives the rest to the next. |

---

## TagListItem

A tag in GET /tags' list: a TagItem, and what a `q` search matched it by.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The tag's key, e.g. `geo:0184`, `occupation:2221`, `employer:5567941173`. Pass it to `tag=`. |
| `code` | `string` | The tag's stable code within its facet, e.g. `driving-license-required`, `permanent`, `0184` (a kommun) or an organisation number. Compare against this, not the key. |
| `label` | `string` | The tag's name. |
| `facetKey` | [`TagFacetKey`](#tagfacetkey) | The tag's facet. |
| `parentKey` | `string \| null` | The key of the nearest visible ancestor. Null for a root. |
| `depth` | `number` | 0 for a root. Not comparable across facets: Sverige and Norge are both Land, at depths 0 and 1. |
| `levels` | [`TagLevel`](#taglevel)[] | The tag's visible ancestors, broadest first, then the tag itself last: breadcrumbs. |
| `openAdsCount` | `number` | Open ads with this tag or any tag under it. |
| `matchedAlias` | `string \| null` | The term `q` matched when the tag's own label does not show it: an English label, or an AF or SCB job title ("doktor" → Läkare, "sthlm" → Stockholm), so a client can show why the tag came up. Null when the label matched, and outside a `q` search. |
| `isSpellingGuess` | `boolean` | True when `q` matched nothing and this tag is the closest spelling ("fullstacj" → Fullstack-utvecklare): a "did you mean". Then every row on the page is a guess. |
| `childCount` | `number` | How many visible children it has: what GET /tags?parentKey=<key> lists, in tree order. |
| `childrenWithOpenAdsCount` | `number` | How many of its children have open ads: what GET /tags?parentKey=<key>&sort=openAds lists. |
| `withNeighboursKey` | `string \| null` | For a place with neighbours: the key that matches the place and the places around it (it ends in `~grannar`). Use it as given. Null for other tags and for places without neighbours (Gotland, sites). |
| `section` | `string` | The suggestion section of its facet (TagFacet.section). |

---

## QueryReading

What a typed search means, as /search parameters, with the spans it was read from.

| Field | Type | Description |
|---|---|---|
| `spans` | [`ReadingSpan`](#readingspan)[] | The typed text, span by span, in order. |
| `tags` | `string[]` | Values for `tag=`, one per parameter, ready to send as they are. |
| `words` | `string[]` | Words for the free-text search; join them with spaces as `q`. |
| `excludeWords` | `string[]` | Negated words; send each as `excludeText=`. |

---

## SuggestionTarget

The part of a search box's text the suggestions complete: replace `q` from `start` with the chosen tag.

| Field | Type | Description |
|---|---|---|
| `text` | `string` | The part of `q` the suggestions complete. |
| `start` | `number` | Where that part starts in `q`, as a string offset. |
| `isNegated` | `boolean` | True when the text follows a negation ("kock ej bem"): a chosen tag should go in as `tag=-<key>`. |

---

## TagSuggestionSection

One section of GET /tags?q=…&sections=true: its tags, broadest first, with the narrower under them.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The section's key: `occupation`, `place`, `employer` or `other`. |
| `label` | `string` | The section's name in Swedish. |
| `labelEn` | `string \| null` | The section's name in English. Null when there is none. |
| `slots` | `number` | Rows it gets in a sectioned answer, a child counting as a row; a section with fewer matches gives the rest to the next. |
| `total` | `number` | How many tags matched in the section, shown or not. |
| `results` | [`TagSuggestion`](#tagsuggestion)[] | The section's tags. |

---

## TagLevel

A step on a tag's path from its root, broadest first.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The tag's key. |
| `code` | `string` | The tag's stable code within its facet (TagItem.code). |
| `label` | `string` | The tag's name. |
| `facetKey` | [`TagFacetKey`](#tagfacetkey) | The tag's facet. |

---

## PlaceKind

What a place is: `municipality` (a kommun) or `region` (a län).

```ts
export type PlaceKind = "municipality" | "region";
```

---

## PlaceFacts

Facts about a municipality or län from SCB and Wikidata, each with its date or source table.
Density is `population / landAreaKm2`.

| Field | Type | Description |
|---|---|---|
| `population` | `number \| null` | Population, from SCB. |
| `populationOn` | `string \| null` | The date the population refers to (YYYY-MM-DD, 31 December of SCB's year). |
| `populationTableId` | `string \| null` | The SCB statistical-database table it comes from, e.g. "TAB628" (statistikdatabasen.scb.se). |
| `landAreaKm2` | `number \| null` | Land area in km², water excluded, from SCB. |
| `landAreaOn` | `string \| null` | The date the land area refers to (YYYY-MM-DD, 1 January). |
| `landAreaTableId` | `string \| null` | The SCB statistical-database table the land area comes from. |
| `wikidataId` | `string \| null` | The place's Wikidata item, e.g. "Q506250": the source of `seatName`, `website`, `foundedYear` and `coatOfArmsUrl`. |
| `seatName` | `string \| null` | The seat: a municipality's main town (centralort), a län's capital (residensstad). Null when Wikidata names none or several. |
| `website` | `string \| null` | The website Wikidata gives. For a län it may be the county administrative board's or the region's, and it can be out of date. |
| `foundedYear` | `number \| null` | The year Wikidata gives for its founding; 1971 for most municipalities. |
| `coatOfArmsUrl` | `string \| null` | The coat of arms on Wikimedia Commons (a Special:FilePath URL, which serves the file). Each file has its own licence there. |

---

## PlaceEncyclopedia

The lead of the place's Swedish Wikipedia article, word for word except that sentences about politics are left out.
Show it as a quote from Wikipedia, with a link to `url`, the licence and the revision date, as CC BY-SA 4.0 requires.
Counts in it are as of the revision; `facts` has SCB's current figures.

| Field | Type | Description |
|---|---|---|
| `excerpt` | `string` | The lead's first sentence, word for word, e.g. "Kiruna kommun är en kommun i Norrbottens län."; up to about 200 characters. |
| `source` | `"wikipedia"` | `wikipedia` (Swedish Wikipedia). |
| `url` | `string` | Permanent link to the article revision the text is from; it also leads to the article and its authors. |
| `license` | `string` | The licence, `CC BY-SA 4.0`. |
| `licenseUrl` | `string` | The licence's deed, to link beside its name. |
| `revisedAt` | `Date` | When that revision of the article was saved: the date to show beside the quote. |
| `text` | `string` | Plain text, one paragraph per line; the words as the article shows them. From one sentence to about 1,200 characters. |

---

## JobAdSection

One section of the ad's text: the text under one heading, with what the heading is about.
Together, the sections are the whole ad text, cleaned up.

| Field | Type | Description |
|---|---|---|
| `n` | `number` | 1-based reading order in the ad. |
| `heading` | `string \| null` | The heading as the ad wrote it. Null for the intro, the text before the first heading. |
| `subheading` | `string \| null` | A second heading directly under `heading` ("Kvalifikationer" → "Vi söker dig som har:"). Null when there is none. |
| `category` | [`SectionCategory`](#sectioncategory) \| `null` | What the section is about. Null when the heading is not recognised, which is more common in English ads. |
| `categoryLabel` | `string \| null` | The category's Swedish name, e.g. "Om rollen". Null when `category` is. |
| `requirementKind` | [`RequirementKind`](#requirementkind) \| `null` | `required` when the heading marks the content as a requirement ("Krav:"), `merit` when it marks it as a merit ("Meriterande:"). Null when the heading does not say, which is most often ("Kvalifikationer", "Din profil"); null means "not stated", not "not required". |
| `content` | `string` | The section's own text, its heading not repeated in it: paragraphs, "- " list items and "label: value" fields. |
| `wordCount` | `number` | How many words `content` has. |
| `parts` | [`SectionPart`](#sectionpart)[] | `content` split into paragraphs, lists and fields. Empty on a few older sections; render `content` then. |

---

## JobAdKeyword

A keyword on one ad, with how much the ad leans on it.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | A stable key; it works in `/search?keyword=`. |
| `label` | `string` | The keyword as it reads. |
| `kind` | [`KeywordKind`](#keywordkind) | What the keyword is. Group on this rather than showing one flat list. |
| `weight` | `number` | How much the ad leans on the keyword. Compare only within one ad. |

---

## JobAdTagItem

A tag on one ad or employer, with what it means there.
JobAdDetail.tags lists every ancestor too, in facet order.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The tag's key; it works in `tag=`. |
| `code` | `string` | The tag's stable code within its facet, e.g. `driving-license-required`, `permanent`, `0184` (a kommun) or an organisation number. Compare against this, not the key. |
| `label` | `string` | The tag's name. |
| `facetKey` | [`TagFacetKey`](#tagfacetkey) | The tag's facet. |
| `rootFacetKey` | [`TagFacetKey`](#tagfacetkey) | The root facet the tag's facet is under, e.g. `geography`. |
| `depth` | `number` | 0 for a root tag, one more for each step down the tag tree. |
| `isDirect` | `boolean` | True when no other tag of the same facet on this ad lies under it: the most specific tag in its facet. In Sista ansökningsdag, `deadline:3-dagar` is direct and the `deadline:7-dagar` that contains it is not. |
| `isPrimary` | `boolean` | The ad's main tag in the facet, from AF's own field (its kommun, its occupation) or its workplace address, rather than read from the text. One per facet, except the area facet, where an ad in, say, Närke is primary in Närke and in Svealand alike; show this one when showing one tag per facet. |

---

## EmployerWithOrganization

An employer with its hiring counts and its register data, as JobAdDetail.employer embeds it.

| Field | Type | Description |
|---|---|---|
| `organizationNumber` | `string` | The Swedish organisation number, 10 digits without a hyphen; the `:organizationNumber` of GET /employers/:organizationNumber. |
| `tagKey` | `string \| null` | The employer's tag key for `tag=`. Use it as given; never build it from the number. Null briefly for a newly added employer. |
| `name` | `string \| null` | The employer's name in normal case: Bolagsverket's registered name, else SCB's, else the name its ads use. |
| `website` | `string \| null` | The website its ads give, often a jobs page. |
| `totalAdsCount` | `number` | All its ads, open and closed. |
| `openAdsCount` | `number` | Its open ads with a link to apply: the ads `/search?tag=<tagKey>` lists. |
| `distinctHeadlinesCount` | `number` | How many different headlines its ads have, open and closed. |
| `municipalitiesCount` | `number` | How many municipalities it has advertised in. |
| `isLikelyStaffingAgency` | `boolean` | True when the employer posts many near-identical ads: at least 20 ads, and on average five or more per distinct headline. Its ad count then overstates its hiring. Not a test of whether it is a staffing agency; the `employer:bemanning-rekrytering` tag is. |
| `primaryOccupationFieldLabel` | `string \| null` | The AF occupation field most of its ads are in, open and closed. Null when none of its ads has one. |
| `employerCategory` | [`EmployerCategoryRef`](#employercategoryref) \| `null` | The employer's most specific category, with the path down to it. Each step has its `tagKey`. Null when the employer has no category. |
| `avatarInitials` | `string \| null` | Initials to show in place of a logo, from the employer's name. Null, like `avatarColor`, when the employer's name or number is missing. |
| `avatarColor` | `string \| null` | Background colour for `avatarInitials`, as `#RRGGBB`. Null when `avatarInitials` is. |
| `nearestAddress`? | [`EmployerNearestAddress`](#employernearestaddress) | Only with `near`: the employer's address nearest the point. |
| `organization` | [`EmployerOrganization`](#employerorganization) \| `null` | What Bolagsverket's and SCB's registers say about the employer. Null when neither knows its organisation number. |

---

## EmployerMetrics

How much the employer has advertised in the last 12 months.

| Field | Type | Description |
|---|---|---|
| `adsLast12MonthsCount` | `number` | Ads it published in the last 12 months. |
| `vacanciesLast12MonthsCount` | `number` | Vacancies in those ads; an ad that gives no number counts as one. |
| `regionsLast12MonthsCount` | `number` | How many län those ads are in. |
| `distinctHeadlinesLast12MonthsCount` | `number` | How many different headlines those ads have. |
| `municipalitiesLast12MonthsCount` | `number` | How many kommuner those ads are in. |

---

## OccupationPathItem

One step on a path through the occupation tree.

| Field | Type | Description |
|---|---|---|
| `conceptId` | `string` | AF's concept id; it works with GET /occupations/:conceptId. |
| `tagKey` | `string \| null` | The tag key for `tag=`. Use it as given; never build it from codes. Null briefly for a newly added occupation. |
| `level` | `number \| null` | The SSYK level 1–4, or 5 for an occupation. Null on a field. |
| `ssykCode` | `string \| null` | The SSYK code on levels 1–4. Null otherwise. |
| `label` | `string` | The name to show. |

---

## EmploymentTypeCode

The employment type as a fixed code; the ad's `employmentTypeLabel` is AF's Swedish label for it.

```ts
export type EmploymentTypeCode = "permanent" | "fixed_term" | "substitute" | "seasonal" | "on_demand" | "summer_job";
```

---

## DurationCode

How long the employment lasts, as a fixed code; the ad's `durationLabel` is AF's Swedish label for it.

```ts
export type DurationCode =
  "up_to_10_days" | "11_days_to_3_months" | "3_to_6_months" | "6_to_12_months" | "6_months_or_more" | "12_months_to_2_years" | "permanent";
```

---

## WorkingHoursTypeCode

Full or part time, as a fixed code; the ad's `workingHoursTypeLabel` is AF's Swedish label for it.

```ts
export type WorkingHoursTypeCode = "full_time" | "part_time";
```

---

## SalaryTypeCode

How the salary is set, as a fixed code; the ad's `salaryTypeLabel` is AF's Swedish label for it.

```ts
export type SalaryTypeCode = "fixed" | "fixed_and_variable" | "variable";
```

---

## JobAdWorkPlace

One place the work is at, beyond the kommun AF files the ad under.

| Field | Type | Description |
|---|---|---|
| `tag` | `string` | The place's tag key, e.g. `geo:0180S-kista`; use it in `tag=`. |
| `label` | `string` | The place's name. |
| `facetKey` | [`TagFacetKey`](#tagfacetkey) | The place's facet, e.g. `district`. |
| `source` | `"address" \| "text-rule" \| "headline" \| "llm"` | How it was read: `address` (AF's workplace address), `text-rule` or `headline` (the ad's words, by rule), or `llm` (a language model's reading of the ad). |
| `arrangement` | `"one" \| "either" \| "each" \| "area" \| "base"` | How the ad's places relate: `one` place, `either` of them, `each` of them, anywhere in an `area`, or a `base` to travel from. |
| `role` | `"primary" \| "secondary"` | `primary`, or `secondary` for an alternative or occasional place ("alternativt Eskilstuna", "en dag i veckan i Kristinehamn"). |

---

## JobAdUnit

The unit of the employer the job belongs to — its base, which for field and mobile work is the office or depot, not where the work is done — from SCB's register of workplaces (arbetsställen).

| Field | Type | Description |
|---|---|---|
| `name` | `string \| null` | The unit's name, from SCB's register in normal case, e.g. "Prästavångsskolan". Null when SCB names it only by its kind or not at all. |
| `street` | `string \| null` | The unit's street address. Null when SCB's address for it could not be read as a street address. |
| `postalTown` | `string \| null` | The unit's postal town. |
| `employees` | [`EmployeeRange`](#employeerange) \| `null` | How many people work at the unit, as SCB's size class. |
| `source` | [`JobAdUnitSource`](#jobadunitsource) | How the unit was found. |
| `certainty` | `"known" \| "likely"` | `known` (from the ad's own workplace address) or `likely` (a rule measured right in at least 85% of the ads read). |

---

## JobAdPay

What the ad's occupation pays according to SCB's wage statistics: statistics for the occupation, region and sector, not what this employer pays.
Each part names the region and sector it is for.

| Field | Type | Description |
|---|---|---|
| `year` | `string` | The year the statistics are for. |
| `ssykCode` | `string` | The SSYK 2012 group the figures are for. |
| `mean` | [`PayMean`](#paymean) \| `null` | Null when SCB publishes no mean for the group. |
| `spread` | [`PaySpread`](#payspread) \| `null` | Null when SCB publishes no spread for the group. |
| `young` | [`PayYoung`](#payyoung) \| `null` | Null when SCB publishes no figure for those aged 18–24 in the group. |

---

## EmployerHistory

What the employer's own ads say about how it hires.

| Field | Type | Description |
|---|---|---|
| `employmentTypesLast12Months` | [`EmploymentTypeCount`](#employmenttypecount)[] | Its ads from the last 12 months by employment type, most first. Empty when it has none. |
| `sameOccupationGroupLast24MonthsCount` | `number \| null` | How many ads it published in this ad's occupation group in the last 24 months, this ad included. Null when the ad has no occupation group. |

---

## EmploymentExplained

What the ad's employment means, in plain Swedish: its employment type under the law, how long it lasts, who employs you when a staffing or recruitment company advertises, and the benefits the ad names (collective agreement, occupational pension, wellness allowance).
Written with AI: show it as such.

| Field | Type | Description |
|---|---|---|
| `paragraphs` | `string[]` | The explanation's paragraphs, in reading order, the employer's name filled in; the benefits the ad names come last ("Annonsen nämner tjänstepension och friskvårdsbidrag.") unless a collective agreement is among them (see `closing`). Can be empty when only `closing` has something to say. |
| `closing` | `string \| null` | The closing line, shown after the paragraphs: the note that a collective agreement can set other rules than the law, or, when the ad names one, that note with the benefits ("Annonsen nämner kollektivavtal, och ett kollektivavtal kan ge andra regler än lagen."). Null when the ad has nothing but benefits without a collective agreement. |
| `text` | `string` | The paragraphs, the closing line and the AI notice, as one text to show or pass on whole. |
| `aiGenerated` | `true` | True: the text was written with AI. Show it as such. |
| `source` | `string` | The law the explanation rests on. |
| `provenance` | [`EmploymentExplainedProvenance`](#employmentexplainedprovenance) |  |

---

## JobAdRequirements

AF's requirements of one kind (required or merit), by what they are about.

| Field | Type | Description |
|---|---|---|
| `skills` | [`JobAdRequirement`](#jobadrequirement)[] | Skills. |
| `languages` | [`JobAdRequirement`](#jobadrequirement)[] | Languages. |
| `workExperiences` | [`JobAdRequirement`](#jobadrequirement)[] | Occupations the employer wants experience in; `conceptId` works with GET /occupations/:conceptId. |
| `education` | [`JobAdRequirement`](#jobadrequirement)[] | Fields of education. |
| `educationLevel` | [`JobAdRequirement`](#jobadrequirement)[] | Levels of education. |

---

## JobAdApplication

How to apply for the ad.
A field is null when the ad does not say.

| Field | Type | Description |
|---|---|---|
| `url` | `string \| null` | Where to apply, when the employer takes applications outside Platsbanken. |
| `email` | `string \| null` | An email address to apply to. |
| `reference` | `string \| null` | The employer's reference for the position, to quote when applying. |
| `information` | `string \| null` | Free-text application instructions. |
| `other` | `string \| null` | Any other way to apply that the ad gives. |
| `appliesViaAf` | `boolean \| null` | True when applications go through Platsbanken. |

---

## JobAdContact

A contact person named in the ad.

| Field | Type | Description |
|---|---|---|
| `name` | `string \| null` | The person's name. |
| `contactType` | `string \| null` | The person's role, e.g. "Verksamhetschef". |
| `description` | `string \| null` | Free text about the contact. |
| `email` | `string \| null` | The person's email address. |
| `telephone` | `string \| null` | The person's telephone number. |

---

## SimilarAdsSeed

The seed ad and the match levels actually used, which can be wider than asked.
A level falls back to `any` when the seed has nothing to match on: no municipality, no occupation, or no field. `employerMatch=other` falls back to `any` for an ad with no organization number. `occupationMatch=occupation` becomes `group` for a retired occupation.
With `minResults`, `geoMatch`, then `occupationMatch` and last `employerMatch=other` widen when the levels hold fewer ads than asked. `employerMatch=same` never widens.

| Field | Type | Description |
|---|---|---|
| `id` | `string` | The seed ad's id. |
| `headline` | `string \| null` | The seed ad's headline. |
| `employerMatch` | `"other" \| "same" \| "any"` | The employer level used: `other` (other employers only), `same` or `any`. |
| `employerName` | `string \| null` | The seed ad's employer name. |
| `employerOrgNumber` | `string \| null` | The seed ad's employer organization number. Null when it has none. |
| `occupationMatch` | `"group" \| "occupation" \| "field" \| "any"` | The occupation level used: `occupation`, `group`, `field` or `any`. |
| `occupationConcept` | `{ conceptId: string; label: string \| null } \| null` | The occupation, group or field matched on, with its label; `conceptId` works with GET /occupations/:conceptId. Null when `occupationMatch` is `any`. |
| `geoMatch` | `"any" \| "municipality" \| "neighbours" \| "region"` | The place level used: `municipality`, `neighbours` (the municipality and those around it), `region` (its county) or `any`. |
| `geoCode` | `string \| null` | The municipality's code (`municipality`, `neighbours`) or the county's (`region`). Null when `geoMatch` is `any`. |
| `geoName` | `string \| null` | The place as a page can name it: "Göteborg", "Göteborg med grannar", "Västra Götalands län". Null when `geoMatch` is `any`. |
| `geoTagKey` | `string \| null` | The matched place's tag key, e.g. `geo:1480`, `geo:1480~grannar` or `geo:14`, to link to all its ads. Null when `geoMatch` is `any`. |

---

## EmployerSummary

One employer with its hiring counts: a row of GET /employers.

| Field | Type | Description |
|---|---|---|
| `organizationNumber` | `string` | The Swedish organisation number, 10 digits without a hyphen; the `:organizationNumber` of GET /employers/:organizationNumber. |
| `tagKey` | `string \| null` | The employer's tag key for `tag=`. Use it as given; never build it from the number. Null briefly for a newly added employer. |
| `name` | `string \| null` | The employer's name in normal case: Bolagsverket's registered name, else SCB's, else the name its ads use. |
| `website` | `string \| null` | The website its ads give, often a jobs page. |
| `totalAdsCount` | `number` | All its ads, open and closed. |
| `openAdsCount` | `number` | Its open ads with a link to apply: the ads `/search?tag=<tagKey>` lists. |
| `distinctHeadlinesCount` | `number` | How many different headlines its ads have, open and closed. |
| `municipalitiesCount` | `number` | How many municipalities it has advertised in. |
| `isLikelyStaffingAgency` | `boolean` | True when the employer posts many near-identical ads: at least 20 ads, and on average five or more per distinct headline. Its ad count then overstates its hiring. Not a test of whether it is a staffing agency; the `employer:bemanning-rekrytering` tag is. |
| `primaryOccupationFieldLabel` | `string \| null` | The AF occupation field most of its ads are in, open and closed. Null when none of its ads has one. |
| `employerCategory` | [`EmployerCategoryRef`](#employercategoryref) \| `null` | The employer's most specific category, with the path down to it. Each step has its `tagKey`. Null when the employer has no category. |
| `avatarInitials` | `string \| null` | Initials to show in place of a logo, from the employer's name. Null, like `avatarColor`, when the employer's name or number is missing. |
| `avatarColor` | `string \| null` | Background colour for `avatarInitials`, as `#RRGGBB`. Null when `avatarInitials` is. |
| `nearestAddress`? | [`EmployerNearestAddress`](#employernearestaddress) | Only with `near`: the employer's address nearest the point. |

---

## EmployerCategoryRef

An employer's category in the employer tree, with the path down to it from the top.

| Field | Type | Description |
|---|---|---|
| `id` | `string` | The category's id, e.g. `typ:bemanning`. |
| `label` | `string` | The category's name. |
| `tagKey` | `string \| null` | The category's tag key for `tag=`. Use it as given; never build it from `id`. Null for a category that is not a filter. |
| `path` | `{ id: string; label: string; tagKey: string \| null }[]` | From the top category down to and including this one, e.g. Privat › Företag › Vård & omsorg, each with its `tagKey`. |

---

## EmployerNearestAddress

The employer's address nearest `near`, on GET /employers.

| Field | Type | Description |
|---|---|---|
| `street` | `string` | The street address as its ads most often write it, e.g. "Rönnowsgatan 8C". |
| `postalTown` | `string \| null` | The postal town. Null when it is not known. |
| `name` | `string \| null` | What its ads most often call the workplace there, else SCB's name for its workplace there. Null when neither names it. |
| `roles` | [`EmployerAddressRole`](#employeraddressrole)[] | What the address is to the employer. |
| `distanceKm` | `number` | The distance from `near`, in km, to one decimal. |
| `point` | [`GeoPoint`](#geopoint) | Where the address is. |

---

## EmployerOrganization

What Bolagsverket's and SCB's registers say about an employer, and texts about it.

| Field | Type | Description |
|---|---|---|
| `registeredName` | `string \| null` | The name as Bolagsverket registered it, else as SCB has it, often in upper case. Null without a register match. `name` is the same name in normal case. |
| `tradeNames` | `string[]` | Trade names registered with Bolagsverket, in its order, e.g. "Hyr en student i Sverige". Empty when there are none. |
| `legalForm` | `string \| null` | The legal form: Bolagsverket's (Aktiebolag, Ekonomisk förening, …), else SCB's category for bodies Bolagsverket does not register, which is a plural (Kommuner, Statliga enheter, …). |
| `industries` | [`EmployerIndustry`](#employerindustry)[] | SCB's SNI 2025 industry codes, largest share of activity first; the first is the main industry. Empty when SCB records none. |
| `industrySectionCode` | `string \| null` | The SNI section letter of the first industry, e.g. "O". |
| `industrySectionLabel` | `string \| null` | The SNI section's name. |
| `employees` | [`EmployeeRange`](#employeerange) \| `null` | How many people the organisation employs in all, as SCB's size class. Null when SCB has no class for it. |
| `postalTown` | `string \| null` | Postal town of the registered address, not where the employer hires. |
| `registeredOn` | `string \| null` | When Bolagsverket registered it (YYYY-MM-DD). Null for bodies Bolagsverket does not register. |
| `isOperating` | `boolean \| null` | Whether SCB counts the organisation as operating. Null without an SCB match. |
| `deregisteredOn` | `string \| null` | When Bolagsverket deregistered it (YYYY-MM-DD). Null while it is registered. Open ads can outlive this date. |
| `deregistrationReason` | `string \| null` | Bolagsverket's reason for deregistering it, e.g. "Fusion", "Likvidation". |
| `businessDescription` | `string \| null` | The business purpose from the articles of association, as Bolagsverket registered it; often formal ("Bolaget skall bedriva …"). |
| `presentation` | `string \| null` | How the employer presents itself, in its own words from its ads' "om företaget" sections: a quote, to be shown as the employer's. Null when none of its ads has one, and for a kommun, whose ads present one unit at a time. |
| `presentationSource` | [`TextSource`](#textsource) \| `null` | `employer_ads` when there is a presentation: credit it to the employer's own ads. Null when `presentation` is null. |
| `presentationAdCount` | `number \| null` | How many of its ads carry exactly this text; a higher count means a more established text. Null when `presentation` is null. |
| `description` | [`EmployerDescription`](#employerdescription) \| `null` | A short text jobbtack wrote about the employer, not a quote. Null for most employers; a kommun's text is on its `place` instead. |
| `ongoingProcedures` | [`EmployerProcedure`](#employerprocedure)[] | Ongoing winding-up or restructuring procedures, in Bolagsverket's order. Empty when there are none. |

---

## EmployerPlace

The municipality or län an employer governs, with its facts.

| Field | Type | Description |
|---|---|---|
| `code` | `string` | SCB's code: four digits for a municipality, two for a län. |
| `tagKey` | `string \| null` | The tag key for `tag=`. Use it as given; never build it from `code`. Null briefly for a newly added place. |
| `kind` | `"municipality" \| "region"` | What the place is: `municipality` or `region` (a län). |
| `name` | `string` | The place's name. |
| `regionCode` | `string \| null` | The code of the län a municipality is in. Null for a län. |
| `regionName` | `string \| null` | The name of the län a municipality is in. Null for a län. |
| `regionTagKey` | `string \| null` | The län's tag key. Null for a län. |
| `openAdsCount` | `number` | Open ads carrying the place's tag: ads filed under the place or naming it as a work place; for a län, those of all its municipalities. Not the employer's own ads, which are the employer's `openAdsCount`. |
| `facts` | [`PlaceFacts`](#placefacts) \| `null` | The place's population, area, seat, website, founding year and coat of arms. Null when there are none. |
| `encyclopedia` | [`PlaceEncyclopedia`](#placeencyclopedia) \| `null` | The lead of the place's Wikipedia article. Null when there is none. |

---

## EmployerAddress

One of an employer's addresses, on GET /employers/:organizationNumber.

| Field | Type | Description |
|---|---|---|
| `street` | `string` | The street address as its ads most often write it. |
| `postcode` | `string` | The postcode, five digits. |
| `postalTown` | `string \| null` | The postal town. Null when it is not known. |
| `name` | `string \| null` | What its ads most often call the workplace there, else SCB's name for its workplace there. Null when neither names it. |
| `roles` | [`EmployerAddressRole`](#employeraddressrole)[] | What the address is to the employer. |
| `openAdsCount` | `number` | Its open ads that give this address. |
| `totalAdsCount` | `number` | Its ads that give this address, open and closed. |
| `employees` | [`EmployeeRange`](#employeerange) \| `null` | How many people work for it at this address, as SCB's size class for its workplace there. Null when SCB has no workplace of it at the address. |
| `firstPublishedOn` | `string \| null` | The first publication date of those ads (YYYY-MM-DD). Null for an address none of its ads gives. |
| `lastPublishedOn` | `string \| null` | The latest publication date of those ads (YYYY-MM-DD). Null for an address none of its ads gives. |
| `point` | [`GeoPoint`](#geopoint) \| `null` | Where the address is. Null when its position is not known. |
| `place` | `{ tagKey: string; label: string } \| null` | The smallest place the address lies in (a district, locality or site, else the municipality), with its tag key for `tag=`. Null when the address is in no known place. |

---

## EmployerUnit

One of an employer's units in SCB's register, on GET /employers/:organizationNumber.

| Field | Type | Description |
|---|---|---|
| `name` | `string \| null` | The unit's name, from SCB's register in normal case. Null when SCB names it only by its kind or not at all. |
| `street` | `string \| null` | The unit's street address. Null when SCB's address for it could not be read as a street address. |
| `postcode` | `string \| null` | The postcode, five digits. Null with `street`. |
| `postalTown` | `string \| null` | The postal town. |
| `employees` | [`EmployeeRange`](#employeerange) \| `null` | How many people work at the unit, as SCB's size class. |
| `openAdsCount` | `number` | The employer's open ads that belong to this unit (JobAdDetail.unit). |
| `point` | `{ lat: number; lon: number } \| null` | Where the unit is. Null when SCB gives no point. |
| `place` | `{ tagKey: string; label: string } \| null` | The smallest place the unit lies in, with its tag key for `tag=`. Null when it is in no known place. |

---

## OccupationKind

What an occupation node is: `ssyk` (an SSYK group, levels 1–4), `occupation` (level 5, the occupation a job ad names) or `field` (one of AF's 21 occupation fields, e.g. Data/IT, which stand outside the SSYK levels).

```ts
export type OccupationKind = "ssyk" | "occupation" | "field";
```

---

## ScbMatch

How a node's `scbCode` relates to AF's code: `same`, `level_3` (an AF manager code, looked up by its level-3 group) or `partial` (AF's group also covers SCB groups that AF has no group for).

```ts
export type ScbMatch = "same" | "level_3" | "partial";
```

---

## OccupationNode

One node of the occupation tree: an SSYK group (levels 1–4), an occupation (level 5) or an AF occupation field.

| Field | Type | Description |
|---|---|---|
| `tagKey` | `string \| null` | The tag key for `tag=`. Use it as given; never build it from codes. Null briefly for a newly added occupation. |
| `conceptId` | `string` | AF's concept id. A job ad carries it as `occupationConceptId` (an occupation), `occupationGroupConceptId` (level 4) or `occupationFieldConceptId` (a field). |
| `kind` | [`OccupationKind`](#occupationkind) | What the node is. A field has no level and no parent. |
| `level` | `number \| null` | The SSYK 2012 level, 1 (broadest) to 4 (occupation group), or 5 for an occupation. Null on a field. |
| `parentConceptId` | `string \| null` | The concept id of the node above. Null on level 1 and on a field. |
| `ssykCode` | `string \| null` | The SSYK 2012 code as AF uses it on levels 1–4, e.g. "532", "5321" or "1250". Null on occupations and fields. |
| `label` | `string` | The name to show. |
| `scbCode` | `string \| null` | The SSYK code to look the node up by in SCB's statistics: usually its own code, three digits for a level-4 group that SCB counts only at level 3. Null on occupations and fields. |
| `scbMatch` | [`ScbMatch`](#scbmatch) \| `null` | How `scbCode` relates to AF's code. Null on occupations and fields. |
| `isDeprecated` | `boolean` | True for an occupation AF has deprecated; older ads still carry it. |
| `fieldConceptId` | `string \| null` | The concept id of the AF occupation field this level-4 group or occupation belongs to. Null otherwise. |
| `openAdsCount` | `number` | Open ads: the ads `/search?tag=<tagKey>` lists. A group's count can differ from the sum of its occupations', since an ad can be filed under another group than its occupation's. |

---

## SimilarOccupation

An occupation whose ads read like another one's, for OccupationDetail.similar.

| Field | Type | Description |
|---|---|---|
| `conceptId` | `string` | AF's concept id; it works with GET /occupations/:conceptId. |
| `tagKey` | `string \| null` | The tag key for `tag=`. Use it as given; never build it from codes. Null briefly for a newly added occupation. |
| `label` | `string` | The name to show. |
| `ssykCode` | `string \| null` | Its group's SSYK code, since an occupation has none of its own. |
| `similarity` | `number` | How alike the ads read, from 0.5 to 1 (alike), to two decimals. |
| `openAdsCount` | `number` | Open ads: the ads `/search?tag=<tagKey>` lists. |

---

## PathParamDescription

One path parameter: a `:name` in a route's path.

| Field | Type | Description |
|---|---|---|
| `name` | `string` | The name after the colon. |
| `description` | `string` | What value goes there. |

---

## ParamDescription

One query parameter.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The key as it goes in the query string. |
| `kind` | [`FieldKind`](#fieldkind) | string, number, decimal (a number that takes fractions), boolean, date, enum or array. |
| `element` | [`FieldKind`](#fieldkind) \| `null` | For an array, the kind of each value. |
| `type` | `string` | The type spelled out: `string`, `string[]`, `"hybrid" \| "fts" \| "vector"`. |
| `repeatable` | `boolean` | True when the key can be given more than once. The parameter's description says how the values combine. |
| `required` | `boolean` | True when leaving it out is a 400. |
| `description` | `string \| null` | What the parameter does. |

---

## FieldDescription

One field of a response type.

| Field | Type | Description |
|---|---|---|
| `name` | `string` | The field's name in the JSON. |
| `type` | `string` | The field's type, as TypeScript writes it. |
| `optional` | `boolean` | True when the field can be absent from the object rather than null. |
| `description` | `string \| null` | What the field holds. Null when it has no description. |

---

## CardTag

A tag on a listing card: its key, code, label and facet.

```ts
export type CardTag = Pick<JobAdTagItem, "key" | "code" | "label" | "facetKey">;
```

---

## TagChoice

A tag with enough to show as a chip; GET /tags?key=<key> has the rest.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The tag's key; it works in `tag=`. |
| `code` | `string` | The tag's stable code within its facet (TagItem.code): what a client compares against. |
| `label` | `string` | The tag's name. |
| `facetKey` | `string` | The tag's facet. |

---

## KeywordKind

What a keyword is: `qualification` (something a person has or lacks), `tool`, `subject` (what the job is about) or `trait`.

```ts
export type KeywordKind = "qualification" | "tool" | "subject" | "trait";
```

---

## LocationPrecision

How exact a row is: `spot` (the job is here: its workplace address or unit), `area` (somewhere in the place: a district, tätort, site or kommun), `base` (the unit or office a job done elsewhere belongs to; only with `includeBases`).

```ts
export type LocationPrecision = "spot" | "area" | "base";
```

---

## WorkMode

How the job is done, as read from the ad's tags and employer: `on-site`, `client-site` (at clients' sites, as for a staffing employer), `homes` (in clients' or patients' homes), `mobile` (moving between sites, as building trades, installers and drivers), `several-sites`, `hybrid`, `remote`, or `unstated` (the ad says nothing beyond a place).

```ts
export type WorkMode = "on-site" | "client-site" | "homes" | "mobile" | "several-sites" | "hybrid" | "remote" | "unstated";
```

---

## TagFacetRetrieval

`full`: small enough to fetch whole and cache. `search`: too large; find tags with GET /tags?q= (Arbetsgivare).

```ts
export type TagFacetRetrieval = "full" | "search";
```

---

## ReadingSpan

One span of a typed search, in the order typed.

| Field | Type | Description |
|---|---|---|
| `kind` | [`ReadingSpanKind`](#readingspankind) | What the span is. |
| `typed` | `string` | The span as typed. |
| `start` | `number` | Offset in `q` where the span starts. |
| `end` | `number` | Offset in `q` just after the span. |
| `text` | `string` | The span's meaning: as typed, or rewritten (ssk → sjuksköterska). For a word, what goes to the free-text search. |
| `isNegated` | `boolean` | True for a tag or word after a negation ("ej", "inte", "utan", "not", a leading "-"): to leave out, not to find. |
| `tag` | [`TagChoice`](#tagchoice) \| `null` | For a tag span, the tag. Null for every other kind. |
| `alsoTags` | [`TagChoice`](#tagchoice)[] | For a tag span, other tags in the same facet that the words name equally well (two occupations both titled Undersköterska). The reading's `tags` ORs them with `tag`. Empty for every other kind. |
| `alternatives` | [`TagChoice`](#tagchoice)[] | For a tag span, the other tags the same words name, best first. Empty for every other kind. |

---

## TagSuggestion

A tag in a sectioned answer, with the narrower matching tags shown under it.
Each tag is shown once in the answer.

| Field | Type | Description |
|---|---|---|
| `key` | `string` | The tag's key, e.g. `geo:0184`, `occupation:2221`, `employer:5567941173`. Pass it to `tag=`. |
| `code` | `string` | The tag's stable code within its facet, e.g. `driving-license-required`, `permanent`, `0184` (a kommun) or an organisation number. Compare against this, not the key. |
| `label` | `string` | The tag's name. |
| `facetKey` | [`TagFacetKey`](#tagfacetkey) | The tag's facet. |
| `parentKey` | `string \| null` | The key of the nearest visible ancestor. Null for a root. |
| `depth` | `number` | 0 for a root. Not comparable across facets: Sverige and Norge are both Land, at depths 0 and 1. |
| `levels` | [`TagLevel`](#taglevel)[] | The tag's visible ancestors, broadest first, then the tag itself last: breadcrumbs. |
| `openAdsCount` | `number` | Open ads with this tag or any tag under it. |
| `matchedAlias` | `string \| null` | The term `q` matched when the tag's own label does not show it: an English label, or an AF or SCB job title ("doktor" → Läkare, "sthlm" → Stockholm), so a client can show why the tag came up. Null when the label matched, and outside a `q` search. |
| `isSpellingGuess` | `boolean` | True when `q` matched nothing and this tag is the closest spelling ("fullstacj" → Fullstack-utvecklare): a "did you mean". Then every row on the page is a guess. |
| `childCount` | `number` | How many visible children it has: what GET /tags?parentKey=<key> lists, in tree order. |
| `childrenWithOpenAdsCount` | `number` | How many of its children have open ads: what GET /tags?parentKey=<key>&sort=openAds lists. |
| `withNeighboursKey` | `string \| null` | For a place with neighbours: the key that matches the place and the places around it (it ends in `~grannar`). Use it as given. Null for other tags and for places without neighbours (Gotland, sites). |
| `section` | `string` | The suggestion section of its facet (TagFacet.section). |
| `children` | [`TagListItem`](#taglistitem)[] | The narrower matching tags shown under this one. |

---

## SectionCategory

What a section is about: `intro`, `about_company`, `about_role`, `about_candidate`, `conditions`, `application`, or `generic` (a heading not about this job, e.g. "Sökord").

```ts
export type SectionCategory = "intro" | "about_company" | "about_role" | "about_candidate" | "conditions" | "application" | "generic";
```

---

## RequirementKind

Whether a heading marks its content as a requirement (`required`) or as a merit (`merit`).

```ts
export type RequirementKind = "required" | "merit";
```

---

## SectionPart

One piece of a section: a paragraph, a list, or a `label: value` field, told apart by `kind`.

```ts
export type SectionPart =
  { kind: "paragraph"; text: string } | { kind: "list"; items: string[] } | { kind: "field"; label: string; value: string };
```

---

## EmployeeRange

How many people work there, as SCB's size class: a range, never a count.

| Field | Type | Description |
|---|---|---|
| `min` | `number` | The fewest the class holds; 0 for no employees. |
| `max` | `number \| null` | The most the class holds. Null for the largest class, 10,000 or more. |
| `label` | `string` | The class in Swedish, e.g. "5 000–9 999 anställda", "Inga anställda". |

---

## JobAdUnitSource

How the unit was found: `unit-address` (the ad's workplace address is the unit's), `unit-near` (its address is within 100 m of the unit), `headline-name` (the headline or AF's workplace name names the unit), `only-unit-in-kommun` (the employer's only unit in the ad's kommuner), `llm` (a language model's choice).

```ts
export type JobAdUnitSource = "unit-address" | "unit-near" | "headline-name" | "only-unit-in-kommun" | "llm";
```

---

## PayMean

The mean pay in the occupation, for the narrowest region and sector SCB publishes it for.

| Field | Type | Description |
|---|---|---|
| `base` | `number \| null` | Mean base pay, kronor a month, before allowances. Null when SCB hides it. |
| `monthly` | `number` | Mean monthly pay, kronor, with fixed allowances (such as for unsocial hours); the gap to `base` is roughly what allowances add. |
| `employees` | `number \| null` | How many people the figure covers, rounded by SCB. Null when SCB hides it. |
| `region` | [`PayScope`](#payscope) \| `null` | The riksområde the figure is for; null when it is for the whole country. |
| `sector` | [`PayScope`](#payscope) \| `null` | The sector the figure is for; null when it is for all sectors. |

---

## PaySpread

How monthly pay in the occupation is spread across the whole country.

| Field | Type | Description |
|---|---|---|
| `median` | `number` | The median monthly pay, kronor. |
| `p10` | `number \| null` | A tenth earn less than this. Null when SCB hides it. |
| `p25` | `number \| null` | A quarter earn less than this. Null when SCB hides it. |
| `p75` | `number \| null` | A quarter earn more than this. Null when SCB hides it. |
| `p90` | `number \| null` | A tenth earn more than this. Null when SCB hides it. |
| `sector` | [`PayScope`](#payscope) \| `null` | The sector the spread is for; null when it is for all sectors. |

---

## PayYoung

The mean pay of those aged 18–24 in the occupation, across the whole country: roughly what a newcomer earns.

| Field | Type | Description |
|---|---|---|
| `base` | `number \| null` | Mean base pay, kronor a month. Null when SCB hides it. |
| `monthly` | `number` | Mean monthly pay, kronor, with fixed allowances. |
| `sector` | [`PayScope`](#payscope) \| `null` | The sector the figure is for; null when it is for all sectors. |

---

## EmploymentTypeCount

How many of the employer's ads from the last 12 months had one employment type.

| Field | Type | Description |
|---|---|---|
| `code` | `string` | The employment type's code, e.g. `permanent`, `fixed_term`, `on_demand`. |
| `label` | `string` | Its name, e.g. "Tidsbegränsad anställning". |
| `adsCount` | `number` |  |

---

## EmploymentExplainedProvenance

Where the explanation's texts come from.

| Field | Type | Description |
|---|---|---|
| `origin` | `"ai-draft"` | `ai-draft`: written by a language model. |
| `model` | `string` | The model that wrote the texts. |
| `writtenOn` | `string` | When the texts were written, YYYY-MM-DD. |
| `approvedBy` | `string` | Who approved them for use. |
| `approvedOn` | `string` | When they were approved, YYYY-MM-DD. |
| `legallyReviewed` | `boolean` | False: no lawyer has checked the legal statements. |

---

## JobAdRequirement

A requirement AF tagged the ad with from its taxonomy, not read from the ad's text.

| Field | Type | Description |
|---|---|---|
| `conceptId` | `string \| null` | AF's concept id for it. Null when AF gives none. |
| `label` | `string` | Its name, as AF labels it. |

---

## EmployerAddressRole

What an address is to the employer: `workplace` where its ads' work is (for a staffing employer mostly a client's site), `office` an office rather than where the work is (such as a head office), `registered` its address in SCB's register, `scb-workplace` one of its workplaces with staff in SCB's register, whether or not an ad gives it.
One address can have several roles, such as registered and office.

```ts
export type EmployerAddressRole = "workplace" | "office" | "registered" | "scb-workplace";
```

---

## GeoPoint

A point in WGS 84 coordinates.

| Field | Type | Description |
|---|---|---|
| `lat` | `number` | Latitude in degrees. |
| `lon` | `number` | Longitude in degrees. |

---

## EmployerIndustry

One SNI 2025 industry of an employer.

| Field | Type | Description |
|---|---|---|
| `code` | `string` | Five-digit SNI 2025 code, e.g. "78201". |
| `label` | `string` | The industry's name. |

---

## TextSource

Who a text about an organisation is from: `employer_ads` (the employer's own words, a quote), `jobbtack` (written by jobbtack) or `wikipedia` (a sourced text, with its URL and licence).

```ts
export type TextSource = "employer_ads" | "jobbtack" | "wikipedia";
```

---

## EmployerDescription

A text about an organisation written by someone other than the organisation.

| Field | Type | Description |
|---|---|---|
| `text` | `string` | Two to four sentences in Swedish, written by a language model from the employer's ads and register data and checked against them. Show it as jobbtack's text, not as a quote. |
| `recipe` | `string` | The prompt version that wrote the text, e.g. `describe-v5`. |
| `source` | [`TextSource`](#textsource) | Who wrote it: `jobbtack`. |
| `sourceUrl` | `string \| null` | Where the text can be read at its source. Null for jobbtack's own. |
| `sourceLicense` | `string \| null` | The source's licence, to show with the text. Null for jobbtack's own. |

---

## EmployerProcedure

An ongoing insolvency, liquidation or reorganisation procedure, as Bolagsverket registers it.

| Field | Type | Description |
|---|---|---|
| `code` | `string` | Bolagsverket's code, e.g. "KK-AVOMFO". |
| `label` | `string \| null` | Bolagsverket's name for the procedure, e.g. "Konkurs". Null when the code has no name. |
| `kind` | `"insolvency" \| "liquidation" \| "reorganization" \| null` | `insolvency` (bankruptcy, company reconstruction, composition or resolution), `liquidation`, or `reorganization` (merger, division or change of legal form). The first two are worth warning a job seeker about; `reorganization` is an ordinary corporate change. Null when the code has no kind. |

---

## FieldKind

```ts
export type FieldKind = "string" | "number" | "decimal" | "boolean" | "date" | "enum" | "array";
```

---

## ReadingSpanKind

What a span of a typed search is: `tag`, `word` (free text), or a word that shapes the reading: `negation`, `conjunction`, `filler` or `boundary`.

```ts
export type ReadingSpanKind = "tag" | "word" | "negation" | "conjunction" | "filler" | "boundary";
```

---

## PayScope

A region or sector a pay figure is for: SCB's code and name.

| Field | Type | Description |
|---|---|---|
| `code` | `string` | SCB's code: a riksområde such as `SE23`, or a sector such as `2` (kommunal). |
| `label` | `string` | SCB's name, e.g. "Västsverige", "kommunal sektor". |
