# Knowledge

> What you tell your agents — entries an agent can look up by meaning, written and assigned from your own systems.

What you tell your agents — entries an agent can look up by meaning, written and assigned from your own systems.

When and why to call these: the [knowledge guide](https://docs.lumavox.ai/agents/knowledge.md).

## Categories in use

`GET /v1/knowledge/categories`

Every category your organisation has put on an entry. Not narrowed by any filter — a picker that only offers what survives the current selection empties itself as soon as it is used.

```bash title="curl"
curl "https://api.lumavox.ai/v1/knowledge/categories" \
  -H "Authorization: Bearer $API_KEY"
```

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — Every category, alphabetically |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `data` | array of string |  |
| `next_cursor` | string or null | Pass as `cursor` for the next page; `null` when this is the last. |

## What the knowledge is used for

`GET /v1/knowledge/insights`

Which entries your agents drew on, by how many replies, and the newest questions whose lookups found nothing — the gaps worth filling. Counted from what each reply recorded, over the last `days` (1-90, default 7). An agent with its knowledge in its prompt looks nothing up, so it does not appear here.

```bash title="curl"
curl "https://api.lumavox.ai/v1/knowledge/insights" \
  -H "Authorization: Bearer $API_KEY"
```

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `days` | integer |  | 1–90. Default `7`. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — Use per entry, and the unanswered questions |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `days` | integer |  |
| `entries` | array of KnowledgeEntryUsage |  |
| `entries[].entry_id` | string |  |
| `entries[].replies` | integer | Replies that drew on this entry in the window. |
| `unanswered` | array of UnansweredQuestion | The newest fifty, newest first. |
| `unanswered[].agent_id` | string |  |
| `unanswered[].agent_name` | string |  |
| `unanswered[].asked` | string | What the person said. |
| `unanswered[].asked_at` | string (date-time) |  |
| `unanswered[].outcome` | `"none"`, `"failed"` | `none` — nothing relevant enough; `failed` — the lookup could not be done. |
| `unanswered[].query` | string | What was searched for. |
| `unanswered[].session_id` | string |  |
| `unanswered[].end_user_id` | string or null | Who asked, by `eu_...` id — the conversation is theirs, read per end user. Null once they are deleted. |

## Test a question against the knowledge

`POST /v1/knowledge/search`

Runs one question exactly as an agent's lookup would — the same embedding, the same index, the same relevance floor — and returns the pieces it would be handed, with their scores. Name `agent_id` to search what that agent has; omit it to search every entry. Costs nothing and changes nothing.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/knowledge/search" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"Can I get a refund after 20 days?"}'
```

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `agent_id` | string or null |  | Search what this agent can find. Omitted, every entry of the organisation that can be searched. |
| `max_results` | integer or null |  | 1–8. |
| `query` | string | yes | A question, as a person would ask it. 1–500 characters. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — What a lookup would find |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `404` | `agent_id` is not one of yours. |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |
| `503` | Knowledge search is not configured. |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `embed_ms` | integer or null | How long the question's embedding took; null unasked. |
| `hits` | array of KnowledgeSearchHit |  |
| `hits[].entry_id` | string |  |
| `hits[].score` | number | Cosine similarity, 0-1. Nothing under the platform's `agents.knowledge.min_score` (`GET /v1/platform/limits`) is handed over. |
| `hits[].text` | string | The piece the agent would read. |
| `hits[].title` | string |  |
| `mode` | `"lookup"`, `"prompt"` or null | When `agent_id` was given: `prompt` if that agent has its whole knowledge in its prompt and looks nothing up — the results show what a lookup would find all the same — or `lookup`. Null without one. |
| `outcome` | `"found"`, `"none"`, `"failed"` | `found`, `none` (nothing relevant enough), or `failed`. |
| `query_ms` | integer or null | How long the index search took; null unasked. |

## List or search knowledge

`GET /v1/knowledge`

Every entry in your organisation, most recently changed first, with the agents each is assigned to, a page at a time.

`q` is a plain text match over title, summary and body — **not** the semantic search the agent uses. This is for finding an entry somebody wrote; the agent's own lookup answers a question by meaning.

`agent_id`, `category` and `status` narrow further and combine. `status` is about the latest body: `pending` while it is indexed, `ready` once it is, `failed` with the reason in `error`. `searchable` says whether an agent can find the entry at all — an edited entry stays searchable at its previous version while the new one indexes, and keeps it if the new one fails.

```bash title="curl"
curl "https://api.lumavox.ai/v1/knowledge" \
  -H "Authorization: Bearer $API_KEY"
```

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `q` | string |  | A plain text match over title, summary and body. At most 200 characters. |
| `agent_id` | string |  | Only the entries this agent has. |
| `category` | string |  | At most 120 characters. |
| `status` | `"pending"`, `"ready"`, `"failed"`, `"importing"` |  |  |
| `limit` | integer |  | How many to answer, 1 to 200. 1–200. Default `50`. |
| `cursor` | string |  | The `next_cursor` of the page before. At least 1 characters. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — One page of entries |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `data` | array of KnowledgeInfo |  |
| `data[].agents` | array of AssignedAgent | Which agents have been given this entry. Empty means nobody has yet — the entry exists and no agent can look it up, which is a draft rather than a fault. |
| `data[].agents[].id` | string |  |
| `data[].agents[].name` | string |  |
| `data[].body` | string |  |
| `data[].body_chars` | integer | How long `body` is in full, in characters. A listing on the console sends the first few hundred characters only; read the entry for all of it. |
| `data[].category` | string or null |  |
| `data[].chunks` | integer | How many pieces the body was split into for searching. Most hand-written entries are one. |
| `data[].created_at` | string (date-time) |  |
| `data[].error` | string or null | Why indexing failed, when it did. |
| `data[].id` | string |  |
| `data[].refresh` | `"off"`, `"daily"`, `"weekly"` or null | For an entry made from a web page: how often it is read again. Null for the rest. |
| `data[].refreshed_at` | string (date-time) or null | When the page or file was last read. |
| `data[].searchable` | boolean | Whether an agent given this entry can find it now. False only until its first indexing succeeds; an edit or a failed re-index leaves the previous version searchable. |
| `data[].source_kind` | `"text"`, `"file"`, `"url"` | Where the text came from: `text` (written or pasted), `file` or `url`. |
| `data[].source_name` | string or null | The file's name, or the page's address. |
| `data[].status` | `"pending"`, `"ready"`, `"failed"`, `"importing"` | How the **latest** body went: `importing` while a file or a page is read into text, `pending` while it is indexed, `ready` once it is done, `failed` with the reason in `error`. Indexing takes seconds to a few minutes: an entry is `ready` once the search index returns it. Whether an agent can find the entry is `searchable`: an edited entry stays searchable at its previous version while the new one indexes. |
| `data[].summary` | string or null | One line saying what this entry answers, written by the model when the entry is saved and editable afterwards. **This is what the agent sees on every turn** — the body is fetched only when it chooses to look something up — so a vague line here makes the agent less likely to consult a perfectly good entry. |
| `data[].summary_source` | `"model"`, `"customer"` or null | Who wrote `summary`: `model` (rewritten with every new body) or `customer` (kept through every edit). Null while there is none. |
| `data[].title` | string |  |
| `data[].updated_at` | string (date-time) |  |
| `data[].version` | integer | Moves with every edit. Send it back as `expected_version` to refuse an edit made over somebody else's. |
| `next_cursor` | string or null | Pass as `cursor` for the next page; `null` when this is the last. |

## Write a knowledge entry

`POST /v1/knowledge`

Needs `agent:write`. Counts against your rate limit like a generation, because indexing is model work.

Creates an entry for your organisation and queues it for indexing. It comes back `pending` and **no agent can find it yet** — poll `GET /v1/knowledge/{id}` until `status` is `ready`, or `failed` with the reason in `error`.

`agent_ids` assigns it to any number of agents at once and may be empty — an entry nobody has yet is a draft, not a fault. Assigning later is `PUT /v1/knowledge/{id}/agents`. Ids that are not yours are skipped rather than failing the write.

`summary` is normally left out: the indexer writes a one-line summary for the agent's prompt, which is how it knows a lookup is worth making.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/knowledge" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"body":"...","title":"..."}'
```

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `agent_ids` | array of string |  | At most 200 items. |
| `body` | string | yes | 1–100000 characters. |
| `category` | string or null |  | At most 120 characters. |
| `summary` | string or null |  | At most 200 characters. |
| `title` | string | yes | 1–255 characters. |

**Responses**

| Status | Description |
|---|---|
| `201` | `application/json` — The entry, `pending` until indexed |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `402` | Out of credits, or the organisation is suspended — nothing was charged |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `409` | The organisation already has as many entries as the platform allows. Delete one before adding another. |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`201` response body**

| Field | Type | Description |
|---|---|---|
| `agents` | array of AssignedAgent | Which agents have been given this entry. Empty means nobody has yet — the entry exists and no agent can look it up, which is a draft rather than a fault. |
| `agents[].id` | string |  |
| `agents[].name` | string |  |
| `body` | string |  |
| `body_chars` | integer | How long `body` is in full, in characters. A listing on the console sends the first few hundred characters only; read the entry for all of it. |
| `category` | string or null |  |
| `chunks` | integer | How many pieces the body was split into for searching. Most hand-written entries are one. |
| `created_at` | string (date-time) |  |
| `error` | string or null | Why indexing failed, when it did. |
| `id` | string |  |
| `refresh` | `"off"`, `"daily"`, `"weekly"` or null | For an entry made from a web page: how often it is read again. Null for the rest. |
| `refreshed_at` | string (date-time) or null | When the page or file was last read. |
| `searchable` | boolean | Whether an agent given this entry can find it now. False only until its first indexing succeeds; an edit or a failed re-index leaves the previous version searchable. |
| `source_kind` | `"text"`, `"file"`, `"url"` | Where the text came from: `text` (written or pasted), `file` or `url`. |
| `source_name` | string or null | The file's name, or the page's address. |
| `status` | `"pending"`, `"ready"`, `"failed"`, `"importing"` | How the **latest** body went: `importing` while a file or a page is read into text, `pending` while it is indexed, `ready` once it is done, `failed` with the reason in `error`. Indexing takes seconds to a few minutes: an entry is `ready` once the search index returns it. Whether an agent can find the entry is `searchable`: an edited entry stays searchable at its previous version while the new one indexes. |
| `summary` | string or null | One line saying what this entry answers, written by the model when the entry is saved and editable afterwards. **This is what the agent sees on every turn** — the body is fetched only when it chooses to look something up — so a vague line here makes the agent less likely to consult a perfectly good entry. |
| `summary_source` | `"model"`, `"customer"` or null | Who wrote `summary`: `model` (rewritten with every new body) or `customer` (kept through every edit). Null while there is none. |
| `title` | string |  |
| `updated_at` | string (date-time) |  |
| `version` | integer | Moves with every edit. Send it back as `expected_version` to refuse an edit made over somebody else's. |

## Delete, assign or unassign many entries at once

`POST /v1/knowledge/bulk`

Needs `agent:write`.

`delete` removes up to 200 entries for every agent that had them. `assign` and `unassign` add the named agents to each entry, or take them away, and leave each entry's other agents alone — unlike `PUT /v1/knowledge/{entry_id}/agents`, which replaces one entry's set. Ids that are not yours come back in `skipped`.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/knowledge/bulk" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action":"delete","entry_ids":[]}'
```

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `action` | `"delete"`, `"assign"`, `"unassign"` | yes |  |
| `agent_ids` | array of string |  | For `assign` and `unassign`: the agents, up to 100. At most 100 items. |
| `entry_ids` | array of string | yes | Up to 200 entries. Ids not yours come back in `skipped`. 1–200 items. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — How many entries were acted on, and what changed |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `changed` | integer | Links added or removed, or entries deleted — what the action changed. |
| `entries` | integer | Entries the action was applied to. |
| `skipped` | array of string | Ids sent that are not this organisation's, ignored. |

## Make a knowledge entry from a web page

`POST /v1/knowledge/urls`

Needs `agent:write`. The page is fetched and read into text, then indexed; it comes back `importing`. A PDF, Word or text file online works the same way. `refresh` reads it again daily or weekly and re-indexes it when it has changed. Only the public internet is reached.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/knowledge/urls" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/refunds"}'
```

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `agent_ids` | array of string |  | At most 200 items. |
| `category` | string or null |  | At most 120 characters. |
| `crawl` | boolean |  | Also import the pages this one links to and the site's sitemap lists, in the same part of the site — `https://shop.test/help/` finds `/help/…` and nothing else — each as its own entry with the same agents, category and `refresh`. One level, robots.txt honoured, pages already imported skipped. The response is this page's entry; the others appear as they are found. Default `false`. |
| `max_pages` | integer |  | With `crawl`: the most entries made, this page included. 2–100. Default `25`. |
| `refresh` | `"off"`, `"daily"`, `"weekly"` or null |  | Read the page again on this schedule; when it has changed, the entry is re-indexed. `off` by default. |
| `title` | string or null |  | At most 255 characters. |
| `url` | string | yes | A public http(s) address: a web page, or a PDF, Word, CSV or text file online. 1–2048 characters. |

**Responses**

| Status | Description |
|---|---|
| `202` | `application/json` — The entry, `importing` |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `402` | Out of credits, or the organisation is suspended — nothing was charged |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `409` | The organisation's entry limit. |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`202` response body**

| Field | Type | Description |
|---|---|---|
| `agents` | array of AssignedAgent | Which agents have been given this entry. Empty means nobody has yet — the entry exists and no agent can look it up, which is a draft rather than a fault. |
| `agents[].id` | string |  |
| `agents[].name` | string |  |
| `body` | string |  |
| `body_chars` | integer | How long `body` is in full, in characters. A listing on the console sends the first few hundred characters only; read the entry for all of it. |
| `category` | string or null |  |
| `chunks` | integer | How many pieces the body was split into for searching. Most hand-written entries are one. |
| `created_at` | string (date-time) |  |
| `error` | string or null | Why indexing failed, when it did. |
| `id` | string |  |
| `refresh` | `"off"`, `"daily"`, `"weekly"` or null | For an entry made from a web page: how often it is read again. Null for the rest. |
| `refreshed_at` | string (date-time) or null | When the page or file was last read. |
| `searchable` | boolean | Whether an agent given this entry can find it now. False only until its first indexing succeeds; an edit or a failed re-index leaves the previous version searchable. |
| `source_kind` | `"text"`, `"file"`, `"url"` | Where the text came from: `text` (written or pasted), `file` or `url`. |
| `source_name` | string or null | The file's name, or the page's address. |
| `status` | `"pending"`, `"ready"`, `"failed"`, `"importing"` | How the **latest** body went: `importing` while a file or a page is read into text, `pending` while it is indexed, `ready` once it is done, `failed` with the reason in `error`. Indexing takes seconds to a few minutes: an entry is `ready` once the search index returns it. Whether an agent can find the entry is `searchable`: an edited entry stays searchable at its previous version while the new one indexes. |
| `summary` | string or null | One line saying what this entry answers, written by the model when the entry is saved and editable afterwards. **This is what the agent sees on every turn** — the body is fetched only when it chooses to look something up — so a vague line here makes the agent less likely to consult a perfectly good entry. |
| `summary_source` | `"model"`, `"customer"` or null | Who wrote `summary`: `model` (rewritten with every new body) or `customer` (kept through every edit). Null while there is none. |
| `title` | string |  |
| `updated_at` | string (date-time) |  |
| `version` | integer | Moves with every edit. Send it back as `expected_version` to refuse an edit made over somebody else's. |

## Ask where to upload a file for knowledge

`POST /v1/knowledge/upload-url`

Needs `agent:write`. Returns a short-lived URL to `PUT` the file to, the `Content-Type` to send with it, and the `upload_id` to make the entry with afterwards (`POST /v1/knowledge/files`). **The bytes never pass through this API.** Up to 10 MB: PDF, Word, Excel, OpenDocument, CSV, HTML, Markdown or plain text. A CSV with `question` and `answer` columns becomes one question-and-answer pair per row.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/knowledge/upload-url" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filename":"LX-900 manual.pdf"}'
```

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `filename` | string | yes | The file's name; its extension says how it is read: .pdf, .docx, .xlsx, .xls, .ods, .odt, .csv, .html, .htm, .txt, .md. 1–255 characters. |
| `size_bytes` | integer or null |  | The file's size, when known. At most 10 MB. At least 0. |
| `content_type` | string or null |  | The `Content-Type` the `PUT` will carry, signed into the URL. Unset: the type of the file's extension, which the ticket names. At most 255 characters. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — Where to `PUT` the file |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `402` | Out of credits, or the organisation is suspended — nothing was charged |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `413` | Larger than 10 MB. |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `expires_at` | string (date-time) | When `url` stops accepting the upload. |
| `upload_id` | string |  |
| `url` | string | `PUT` the file's bytes here, with `content_type` as the `Content-Type` and no authentication headers, before `expires_at`. |
| `content_type` | string | The `Content-Type` the `PUT` must carry: it is signed into the URL, and R2 refuses any other. |

## Make a knowledge entry from an uploaded file

`POST /v1/knowledge/files`

Needs `agent:write`. After the `PUT` to the upload URL succeeded: the entry comes back `importing` while the file is read into text, then `pending` while it is indexed, then `ready`. The text is the entry's body, editable like any other. Without a `title` the file's own is used.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/knowledge/files" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filename":"...","upload_id":"..."}'
```

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `agent_ids` | array of string |  | At most 200 items. |
| `category` | string or null |  | At most 120 characters. |
| `filename` | string | yes | 1–255 characters. |
| `title` | string or null |  | At most 255 characters. |
| `upload_id` | string | yes |  |

**Responses**

| Status | Description |
|---|---|
| `202` | `application/json` — The entry, `importing` |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `402` | Out of credits, or the organisation is suspended — nothing was charged |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `404` | Nothing was uploaded for that upload_id. |
| `409` | The organisation's entry limit. |
| `413` | Larger than 10 MB. |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`202` response body**

| Field | Type | Description |
|---|---|---|
| `agents` | array of AssignedAgent | Which agents have been given this entry. Empty means nobody has yet — the entry exists and no agent can look it up, which is a draft rather than a fault. |
| `agents[].id` | string |  |
| `agents[].name` | string |  |
| `body` | string |  |
| `body_chars` | integer | How long `body` is in full, in characters. A listing on the console sends the first few hundred characters only; read the entry for all of it. |
| `category` | string or null |  |
| `chunks` | integer | How many pieces the body was split into for searching. Most hand-written entries are one. |
| `created_at` | string (date-time) |  |
| `error` | string or null | Why indexing failed, when it did. |
| `id` | string |  |
| `refresh` | `"off"`, `"daily"`, `"weekly"` or null | For an entry made from a web page: how often it is read again. Null for the rest. |
| `refreshed_at` | string (date-time) or null | When the page or file was last read. |
| `searchable` | boolean | Whether an agent given this entry can find it now. False only until its first indexing succeeds; an edit or a failed re-index leaves the previous version searchable. |
| `source_kind` | `"text"`, `"file"`, `"url"` | Where the text came from: `text` (written or pasted), `file` or `url`. |
| `source_name` | string or null | The file's name, or the page's address. |
| `status` | `"pending"`, `"ready"`, `"failed"`, `"importing"` | How the **latest** body went: `importing` while a file or a page is read into text, `pending` while it is indexed, `ready` once it is done, `failed` with the reason in `error`. Indexing takes seconds to a few minutes: an entry is `ready` once the search index returns it. Whether an agent can find the entry is `searchable`: an edited entry stays searchable at its previous version while the new one indexes. |
| `summary` | string or null | One line saying what this entry answers, written by the model when the entry is saved and editable afterwards. **This is what the agent sees on every turn** — the body is fetched only when it chooses to look something up — so a vague line here makes the agent less likely to consult a perfectly good entry. |
| `summary_source` | `"model"`, `"customer"` or null | Who wrote `summary`: `model` (rewritten with every new body) or `customer` (kept through every edit). Null while there is none. |
| `title` | string |  |
| `updated_at` | string (date-time) |  |
| `version` | integer | Moves with every edit. Send it back as `expected_version` to refuse an edit made over somebody else's. |

## Read a knowledge entry

`GET /v1/knowledge/{entry_id}`

The entry, how many chunks it indexed to, and which agents have it. This is what to poll after a write until `status` is `ready`.

```bash title="curl"
curl "https://api.lumavox.ai/v1/knowledge/$ENTRY_ID" \
  -H "Authorization: Bearer $API_KEY"
```

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `entry_id` | string | yes |  |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — The entry |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `404` | Unknown knowledge entry id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `agents` | array of AssignedAgent | Which agents have been given this entry. Empty means nobody has yet — the entry exists and no agent can look it up, which is a draft rather than a fault. |
| `agents[].id` | string |  |
| `agents[].name` | string |  |
| `body` | string |  |
| `body_chars` | integer | How long `body` is in full, in characters. A listing on the console sends the first few hundred characters only; read the entry for all of it. |
| `category` | string or null |  |
| `chunks` | integer | How many pieces the body was split into for searching. Most hand-written entries are one. |
| `created_at` | string (date-time) |  |
| `error` | string or null | Why indexing failed, when it did. |
| `id` | string |  |
| `refresh` | `"off"`, `"daily"`, `"weekly"` or null | For an entry made from a web page: how often it is read again. Null for the rest. |
| `refreshed_at` | string (date-time) or null | When the page or file was last read. |
| `searchable` | boolean | Whether an agent given this entry can find it now. False only until its first indexing succeeds; an edit or a failed re-index leaves the previous version searchable. |
| `source_kind` | `"text"`, `"file"`, `"url"` | Where the text came from: `text` (written or pasted), `file` or `url`. |
| `source_name` | string or null | The file's name, or the page's address. |
| `status` | `"pending"`, `"ready"`, `"failed"`, `"importing"` | How the **latest** body went: `importing` while a file or a page is read into text, `pending` while it is indexed, `ready` once it is done, `failed` with the reason in `error`. Indexing takes seconds to a few minutes: an entry is `ready` once the search index returns it. Whether an agent can find the entry is `searchable`: an edited entry stays searchable at its previous version while the new one indexes. |
| `summary` | string or null | One line saying what this entry answers, written by the model when the entry is saved and editable afterwards. **This is what the agent sees on every turn** — the body is fetched only when it chooses to look something up — so a vague line here makes the agent less likely to consult a perfectly good entry. |
| `summary_source` | `"model"`, `"customer"` or null | Who wrote `summary`: `model` (rewritten with every new body) or `customer` (kept through every edit). Null while there is none. |
| `title` | string |  |
| `updated_at` | string (date-time) |  |
| `version` | integer | Moves with every edit. Send it back as `expected_version` to refuse an edit made over somebody else's. |

## Edit a knowledge entry

`PATCH /v1/knowledge/{entry_id}`

Needs `agent:write`. Counts against your rate limit like a generation.

A partial update: fields you omit are left alone. An explicit `null` clears `category`; on `summary` it hands the line back to the indexer, which writes a new one. A `summary` you write is kept through every later edit; one the indexer wrote is rewritten with each new body.

**Only a changed `body` costs anything.** It re-indexes: `status` returns to `pending`, and **the previous version stays searchable until the new one is ready**, so no agent loses the entry meanwhile. Renaming or recategorising is immediate.

One edit reaches every agent the entry is assigned to.

```bash title="curl"
curl -X PATCH "https://api.lumavox.ai/v1/knowledge/$ENTRY_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `entry_id` | string | yes |  |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `agent_ids` | array of string |  | The agents that have this entry, as a complete set — set in the same transaction as the rest of the edit, so a save cannot land half. Omitted, the assignments are left alone. At most 1000 items. |
| `body` | string |  | 1–1000000 characters. |
| `category` | string or null |  | At most 120 characters. |
| `expected_version` | integer |  | The `version` the editor started from. If the entry has been edited since, the edit is refused with 409 rather than writing over somebody else's. |
| `refresh` | `"off"`, `"daily"`, `"weekly"` |  | For an entry made from a web page: how often it is read again. |
| `summary` | string or null |  | At most 200 characters. |
| `title` | string |  | 1–255 characters. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — The entry as it now stands |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `402` | Out of credits, or the organisation is suspended — nothing was charged |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `404` | Unknown knowledge entry id |
| `409` | `expected_version` is older than the entry: somebody changed it since. |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `agents` | array of AssignedAgent | Which agents have been given this entry. Empty means nobody has yet — the entry exists and no agent can look it up, which is a draft rather than a fault. |
| `agents[].id` | string |  |
| `agents[].name` | string |  |
| `body` | string |  |
| `body_chars` | integer | How long `body` is in full, in characters. A listing on the console sends the first few hundred characters only; read the entry for all of it. |
| `category` | string or null |  |
| `chunks` | integer | How many pieces the body was split into for searching. Most hand-written entries are one. |
| `created_at` | string (date-time) |  |
| `error` | string or null | Why indexing failed, when it did. |
| `id` | string |  |
| `refresh` | `"off"`, `"daily"`, `"weekly"` or null | For an entry made from a web page: how often it is read again. Null for the rest. |
| `refreshed_at` | string (date-time) or null | When the page or file was last read. |
| `searchable` | boolean | Whether an agent given this entry can find it now. False only until its first indexing succeeds; an edit or a failed re-index leaves the previous version searchable. |
| `source_kind` | `"text"`, `"file"`, `"url"` | Where the text came from: `text` (written or pasted), `file` or `url`. |
| `source_name` | string or null | The file's name, or the page's address. |
| `status` | `"pending"`, `"ready"`, `"failed"`, `"importing"` | How the **latest** body went: `importing` while a file or a page is read into text, `pending` while it is indexed, `ready` once it is done, `failed` with the reason in `error`. Indexing takes seconds to a few minutes: an entry is `ready` once the search index returns it. Whether an agent can find the entry is `searchable`: an edited entry stays searchable at its previous version while the new one indexes. |
| `summary` | string or null | One line saying what this entry answers, written by the model when the entry is saved and editable afterwards. **This is what the agent sees on every turn** — the body is fetched only when it chooses to look something up — so a vague line here makes the agent less likely to consult a perfectly good entry. |
| `summary_source` | `"model"`, `"customer"` or null | Who wrote `summary`: `model` (rewritten with every new body) or `customer` (kept through every edit). Null while there is none. |
| `title` | string |  |
| `updated_at` | string (date-time) |  |
| `version` | integer | Moves with every edit. Send it back as `expected_version` to refuse an edit made over somebody else's. |

## Delete a knowledge entry

`DELETE /v1/knowledge/{entry_id}`

Needs `agent:write`.

Removes the entry, everything indexed from it, and every assignment to it. **This affects every agent that had it** — unassigning one agent is `PUT /v1/agents/{agent_id}/knowledge` and leaves the entry alone.

```bash title="curl"
curl -X DELETE "https://api.lumavox.ai/v1/knowledge/$ENTRY_ID" \
  -H "Authorization: Bearer $API_KEY"
```

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `entry_id` | string | yes |  |

**Responses**

| Status | Description |
|---|---|
| `204` | The entry is gone, for every agent that had it |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `404` | Unknown knowledge entry id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

## Index a knowledge entry again

`POST /v1/knowledge/{entry_id}/reindex`

Needs `agent:write`. Counts against your rate limit like a generation.

Queues the entry for indexing as it stands — for one that `failed`, say while a provider was down. `status` returns to `pending`; whatever version was searchable stays searchable until the new one is ready. Entries stuck `pending` are retried on their own after half an hour.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/knowledge/$ENTRY_ID/reindex" \
  -H "Authorization: Bearer $API_KEY"
```

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `entry_id` | string | yes |  |

**Responses**

| Status | Description |
|---|---|
| `202` | `application/json` — The entry, `pending` until indexed |
| `401` | Missing or invalid credentials |
| `402` | Out of credits, or the organisation is suspended — nothing was charged |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `404` | Unknown knowledge entry id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`202` response body**

| Field | Type | Description |
|---|---|---|
| `agents` | array of AssignedAgent | Which agents have been given this entry. Empty means nobody has yet — the entry exists and no agent can look it up, which is a draft rather than a fault. |
| `agents[].id` | string |  |
| `agents[].name` | string |  |
| `body` | string |  |
| `body_chars` | integer | How long `body` is in full, in characters. A listing on the console sends the first few hundred characters only; read the entry for all of it. |
| `category` | string or null |  |
| `chunks` | integer | How many pieces the body was split into for searching. Most hand-written entries are one. |
| `created_at` | string (date-time) |  |
| `error` | string or null | Why indexing failed, when it did. |
| `id` | string |  |
| `refresh` | `"off"`, `"daily"`, `"weekly"` or null | For an entry made from a web page: how often it is read again. Null for the rest. |
| `refreshed_at` | string (date-time) or null | When the page or file was last read. |
| `searchable` | boolean | Whether an agent given this entry can find it now. False only until its first indexing succeeds; an edit or a failed re-index leaves the previous version searchable. |
| `source_kind` | `"text"`, `"file"`, `"url"` | Where the text came from: `text` (written or pasted), `file` or `url`. |
| `source_name` | string or null | The file's name, or the page's address. |
| `status` | `"pending"`, `"ready"`, `"failed"`, `"importing"` | How the **latest** body went: `importing` while a file or a page is read into text, `pending` while it is indexed, `ready` once it is done, `failed` with the reason in `error`. Indexing takes seconds to a few minutes: an entry is `ready` once the search index returns it. Whether an agent can find the entry is `searchable`: an edited entry stays searchable at its previous version while the new one indexes. |
| `summary` | string or null | One line saying what this entry answers, written by the model when the entry is saved and editable afterwards. **This is what the agent sees on every turn** — the body is fetched only when it chooses to look something up — so a vague line here makes the agent less likely to consult a perfectly good entry. |
| `summary_source` | `"model"`, `"customer"` or null | Who wrote `summary`: `model` (rewritten with every new body) or `customer` (kept through every edit). Null while there is none. |
| `title` | string |  |
| `updated_at` | string (date-time) |  |
| `version` | integer | Moves with every edit. Send it back as `expected_version` to refuse an edit made over somebody else's. |

## Read a page-made entry again now

`POST /v1/knowledge/{entry_id}/refresh`

Needs `agent:write`. For an entry made from a web page: fetches it now rather than at its next scheduled refresh, and re-indexes it if it changed.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/knowledge/$ENTRY_ID/refresh" \
  -H "Authorization: Bearer $API_KEY"
```

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `entry_id` | string | yes |  |

**Responses**

| Status | Description |
|---|---|
| `202` | `application/json` — The entry |
| `401` | Missing or invalid credentials |
| `402` | Out of credits, or the organisation is suspended — nothing was charged |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `404` | Unknown knowledge entry id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`202` response body**

| Field | Type | Description |
|---|---|---|
| `agents` | array of AssignedAgent | Which agents have been given this entry. Empty means nobody has yet — the entry exists and no agent can look it up, which is a draft rather than a fault. |
| `agents[].id` | string |  |
| `agents[].name` | string |  |
| `body` | string |  |
| `body_chars` | integer | How long `body` is in full, in characters. A listing on the console sends the first few hundred characters only; read the entry for all of it. |
| `category` | string or null |  |
| `chunks` | integer | How many pieces the body was split into for searching. Most hand-written entries are one. |
| `created_at` | string (date-time) |  |
| `error` | string or null | Why indexing failed, when it did. |
| `id` | string |  |
| `refresh` | `"off"`, `"daily"`, `"weekly"` or null | For an entry made from a web page: how often it is read again. Null for the rest. |
| `refreshed_at` | string (date-time) or null | When the page or file was last read. |
| `searchable` | boolean | Whether an agent given this entry can find it now. False only until its first indexing succeeds; an edit or a failed re-index leaves the previous version searchable. |
| `source_kind` | `"text"`, `"file"`, `"url"` | Where the text came from: `text` (written or pasted), `file` or `url`. |
| `source_name` | string or null | The file's name, or the page's address. |
| `status` | `"pending"`, `"ready"`, `"failed"`, `"importing"` | How the **latest** body went: `importing` while a file or a page is read into text, `pending` while it is indexed, `ready` once it is done, `failed` with the reason in `error`. Indexing takes seconds to a few minutes: an entry is `ready` once the search index returns it. Whether an agent can find the entry is `searchable`: an edited entry stays searchable at its previous version while the new one indexes. |
| `summary` | string or null | One line saying what this entry answers, written by the model when the entry is saved and editable afterwards. **This is what the agent sees on every turn** — the body is fetched only when it chooses to look something up — so a vague line here makes the agent less likely to consult a perfectly good entry. |
| `summary_source` | `"model"`, `"customer"` or null | Who wrote `summary`: `model` (rewritten with every new body) or `customer` (kept through every edit). Null while there is none. |
| `title` | string |  |
| `updated_at` | string (date-time) |  |
| `version` | integer | Moves with every edit. Send it back as `expected_version` to refuse an edit made over somebody else's. |

## Set which agents have an entry

`PUT /v1/knowledge/{entry_id}/agents`

Needs `agent:write`.

Replaces the entry's assignments with exactly the agents named — the mirror of `PUT /v1/agents/{agent_id}/knowledge`, which edits the same links from the other end. Declarative: the list is the complete set, and an empty one unassigns it from everybody without deleting anything. Ids that are not yours come back in `skipped`.

```bash title="curl"
curl -X PUT "https://api.lumavox.ai/v1/knowledge/$ENTRY_ID/agents" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_ids":[]}'
```

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `entry_id` | string | yes |  |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `agent_ids` | array of string | yes | The complete set. An empty list clears every assignment, which is a real thing to want and not an error. At most 1000 items. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — How many links were added and removed |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability — a key limited to `tts` gets this from `/v1/images` rather than a 401 |
| `404` | Unknown knowledge entry id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `added` | integer |  |
| `removed` | integer |  |
| `skipped` | array of string | Ids sent that are not this organisation's, ignored. |
