# Conversations

> Conversations with an agent.

Conversations with an agent. There is a streaming door too, on a WebSocket that OpenAPI cannot describe — see *The WebSocket* in the docs.

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

Every operation here takes **an end user's ephemeral token**, never an API key: an API key gets 403. Mint one with `POST /v1/ephemeral-keys` — see [End users and their keys](https://docs.lumavox.ai/agents/end-users.md).

## List conversations

`GET /v1/agent/sessions`

Most recently used first, a page at a time. Messages are not included — read one session for its transcript.

**The key's own end user and nobody else**, and only conversations with the agents the key may talk to.

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

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `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 conversations, most recent first |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `data` | array of SessionInfo |  |
| `data[].agent_id` | string |  |
| `data[].created_at` | string (date-time) |  |
| `data[].end_user_id` | string | Who this conversation is with. Send it again to open another conversation with the same person, carrying their memory. |
| `data[].id` | string |  |
| `data[].title` | string or null |  |
| `data[].turn_seq` | integer |  |
| `data[].updated_at` | string (date-time) |  |
| `next_cursor` | string or null | Pass as `cursor` for the next page; `null` when this is the last. |

## Open or continue a conversation

`POST /v1/agent/sessions`

**Called with the end user's ephemeral key** — the conversation is always that person's, and `end_user_id`, if sent, must name them.

**It continues the person's most recent conversation with this agent, if there is one young enough to continue** — the platform's `agents.sessions.resume_max_age_days` (`GET /v1/platform/limits`), unless the organisation sets its own. Send `resume: false` to always start a new one — that is what a "new chat" button should do. The response's `resumed` says which happened, and a continued conversation arrives with its `messages` already populated and its `turn_seq` past zero.

**Editing the agent reaches this conversation too**, within about a minute, from its next turn onward.

**This call makes no model call and no speech call**, and a new conversation comes back with no messages — even for an agent that has a `first_message`. The opening line is the agent's first turn on `/v1/agent/stream`, written when a client connects, and streamed and spoken a sentence at a time like every other reply. The socket also decides *whether* to greet, and `ready.greeting` says which.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/agent/sessions" \
  -H "Authorization: Bearer $EPHEMERAL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"agent_id":"..."}'
```

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

| Field | Type | Required | Description |
|---|---|---|---|
| `agent_id` | string | yes | Which agent this conversation is with. |
| `end_user_id` | string or null |  | Optional, and only ever the key's own end user — their id from `POST /v1/end-users`, or your own id for them. **A conversation is always the person the ephemeral key was minted for**: the key says who is talking, so this can only confirm it, and naming anybody else is a **403**. What the agent remembers about them — durable facts and the standing relationship — is theirs, and carries into every conversation with any of your agents. Set once, when the session opens. It cannot change afterwards. At most 128 characters. |
| `resume` | boolean or null |  | Whether to continue this person's most recent conversation with this agent instead of starting a new one. **Unset is the useful default**: it resumes when this person has a conversation with this agent young enough to continue — within the organisation's `resume_max_age_days`, else the platform's `agents.sessions.resume_max_age_days` (`GET /v1/platform/limits`) — and starts a new one otherwise. That matches what 'open a conversation with this person' usually means, and it stops an integration that opens a session per page load from scattering somebody's history across a dozen fragments. `false` always starts a new conversation — this is what a 'new chat' button should send. `true` is the same as leaving it unset; it exists so the intent can be stated rather than implied. Nothing is lost by starting fresh: durable facts and the standing relationship belong to the *person*, not the conversation, and are carried into every new one. The response's `resumed` says which happened. |
| `title` | string or null |  | Optional label. At most 255 characters. |

**Responses**

| Status | Description |
|---|---|
| `201` | `application/json` — The conversation — new or continued, per `resumed` |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `403` | The key's scopes do not cover agents, or it is for another agent or person |
| `404` | Unknown agent id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`201` response body**

| Field | Type | Description |
|---|---|---|
| `agent_id` | string |  |
| `created_at` | string (date-time) |  |
| `end_user_id` | string | Who this conversation is with. Send it again to open another conversation with the same person, carrying their memory. |
| `id` | string |  |
| `title` | string or null |  |
| `turn_seq` | integer |  |
| `updated_at` | string (date-time) |  |
| `messages` | MessagePage |  |
| `messages.data` | array of MessageInfo |  |
| `messages.data[].content` | string |  |
| `messages.data[].created_at` | string (date-time) |  |
| `messages.data[].id` | string |  |
| `messages.data[].knowledge` | MessageKnowledge or null | On a reply: what the agent looked up in its knowledge for it — each lookup's query, outcome and the entries it found, with their scores. Null when nothing was looked up. |
| `messages.data[].mood` | string or null | How the agent said it felt when it wrote this reply, when the agent reports moods. Null on user messages, on agents with emotion off, and on any reply where the model did not report a usable one. |
| `messages.data[].mood_intensity` | number or null | How strongly, 0 to 1. Null when the model gave a label without a number — the label is the half it gets right. |
| `messages.data[].role` | `"user"`, `"assistant"` |  |
| `messages.data[].sources` | array of MessageSource | On a reply: the web pages it read, with the agent's `url_context` switch on. Empty when it read none, and on the person's messages. |
| `messages.data[].tool_calls` | array of MessageToolCall | On a reply: the calls of your tools it made, in order, each with its arguments and what it answered. Empty when it called none, and on the person's messages. |
| `messages.next_cursor` | string or null | Pass as `cursor` for the next page; `null` when this is the last. |
| `relation` | RelationInfo or null | How this agent stands with this person, as the conversation opens. **Null when the agent has `relation_enabled` off**, which is not the same as "they have no history": the counters run for every pair regardless, and returning one for an agent that keeps none would show a relationship the model is not being told about. It belongs to the **person**, not the conversation, so it carries across every session with the same `end_user_id`. Here so that rendering it at conversation start needs no second call; `GET /v1/agent/relations` is still how you read it any other time, and the socket's `relation` event is how you learn it moved mid-turn — which is rare, and is the only time it changes. |
| `relation.agent_id` | string |  |
| `relation.agent_name` | string or null | The agent's name, so a list of these is readable without resolving ids. |
| `relation.conversations` | integer | Separate sessions, ever. A stronger signal of familiarity than `turns` — twenty short visits is a longer acquaintance than one long conversation. |
| `relation.disclosure` | number | How much the agent has told **them** about itself, -1 to 1 — the asymmetric half of knowing each other, and the one nothing can count. Deliberately not part of `tier`: an agent can have opened up to somebody it has come to dislike, and folding this into affection would make that state unrepresentable. |
| `relation.end_user_id` | string | The person, as `eu_...`. |
| `relation.first_met_at` | string (date-time) or null |  |
| `relation.last_seen_at` | string (date-time) or null |  |
| `relation.note` | string or null | One line the model wrote about them. **Replaced, not appended** — it is rendered into every future prompt for this pair, so it is bounded by design. |
| `relation.recorded` | integer | How many times the model has recorded a change. Zero on a long history means the tool is not being called, which is the first thing to check when a relationship is not moving. |
| `relation.summary` | string | The relationship as the model is told it — bands and counted phrases, never numbers. **Empty string** when there is nothing worth saying yet, in which case nothing about them is added to the prompt at all — which is a real state and not a missing value. |
| `relation.tier` | `"Despised"`, `"Disliked"`, `"Acquaintances"`, `"Friends"`, `"Good Friends"` | What the agent would call this relationship, and what it is actually told: **Despised, Disliked, Acquaintances, Friends** or **Good Friends**. Derived on every read from the axes below and never stored, so it cannot go stale. The rule is one sentence: *warmth and trust set the direction, familiarity sets how far it can go* — a first conversation caps at Acquaintances however well it went, and the ceiling rises as they talk. Dislike is not capped: an agent can take against somebody on one remark. |
| `relation.trust` | number | -1 to 1, on the same scale. |
| `relation.turns` | integer | Turns exchanged, ever. Counted, not judged. |
| `relation.warmth` | number | -1 to 1. Zero is indifference, which is where every relationship starts. |
| `resumed` | boolean | True when this is a conversation that already existed rather than a new one — see `resume` on the request. Worth branching on: a resumed session arrives with its `messages` already populated and its `turn_seq` past zero, so a client that assumed an empty transcript will render nothing until the next turn. |

## Read a conversation

`GET /v1/agent/sessions/{session_id}`

The transcript, oldest first, exactly as stored — including every turn from before a compaction. **Compaction shortens what the model is sent, never what you can read back.**

**The most recent `limit` messages**, because what a client renders is the end of a conversation.

**To read it all, page backwards**: pass `messages.next_cursor` as `cursor`; it is null once you have reached the start.

Each message carries the `mood` the agent reported when it wrote it, if the agent reports moods.

```bash title="curl"
curl "https://api.lumavox.ai/v1/agent/sessions/$SESSION_ID" \
  -H "Authorization: Bearer $EPHEMERAL_TOKEN"
```

**Path parameters**

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

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `limit` | integer |  | How many messages, counting back from the newest, 1 to 200. The page itself is oldest first. 1–200. Default `50`. |
| `cursor` | string |  | `messages.next_cursor` of the page before, for the older messages. At least 1 characters. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — The conversation and one page of its messages |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability |
| `404` | Unknown session id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `agent_id` | string |  |
| `created_at` | string (date-time) |  |
| `end_user_id` | string | Who this conversation is with. Send it again to open another conversation with the same person, carrying their memory. |
| `id` | string |  |
| `title` | string or null |  |
| `turn_seq` | integer |  |
| `updated_at` | string (date-time) |  |
| `messages` | MessagePage |  |
| `messages.data` | array of MessageInfo |  |
| `messages.data[].content` | string |  |
| `messages.data[].created_at` | string (date-time) |  |
| `messages.data[].id` | string |  |
| `messages.data[].knowledge` | MessageKnowledge or null | On a reply: what the agent looked up in its knowledge for it — each lookup's query, outcome and the entries it found, with their scores. Null when nothing was looked up. |
| `messages.data[].mood` | string or null | How the agent said it felt when it wrote this reply, when the agent reports moods. Null on user messages, on agents with emotion off, and on any reply where the model did not report a usable one. |
| `messages.data[].mood_intensity` | number or null | How strongly, 0 to 1. Null when the model gave a label without a number — the label is the half it gets right. |
| `messages.data[].role` | `"user"`, `"assistant"` |  |
| `messages.data[].sources` | array of MessageSource | On a reply: the web pages it read, with the agent's `url_context` switch on. Empty when it read none, and on the person's messages. |
| `messages.data[].tool_calls` | array of MessageToolCall | On a reply: the calls of your tools it made, in order, each with its arguments and what it answered. Empty when it called none, and on the person's messages. |
| `messages.next_cursor` | string or null | Pass as `cursor` for the next page; `null` when this is the last. |
| `relation` | RelationInfo or null | How this agent stands with this person, as the conversation opens. **Null when the agent has `relation_enabled` off**, which is not the same as "they have no history": the counters run for every pair regardless, and returning one for an agent that keeps none would show a relationship the model is not being told about. It belongs to the **person**, not the conversation, so it carries across every session with the same `end_user_id`. Here so that rendering it at conversation start needs no second call; `GET /v1/agent/relations` is still how you read it any other time, and the socket's `relation` event is how you learn it moved mid-turn — which is rare, and is the only time it changes. |
| `relation.agent_id` | string |  |
| `relation.agent_name` | string or null | The agent's name, so a list of these is readable without resolving ids. |
| `relation.conversations` | integer | Separate sessions, ever. A stronger signal of familiarity than `turns` — twenty short visits is a longer acquaintance than one long conversation. |
| `relation.disclosure` | number | How much the agent has told **them** about itself, -1 to 1 — the asymmetric half of knowing each other, and the one nothing can count. Deliberately not part of `tier`: an agent can have opened up to somebody it has come to dislike, and folding this into affection would make that state unrepresentable. |
| `relation.end_user_id` | string | The person, as `eu_...`. |
| `relation.first_met_at` | string (date-time) or null |  |
| `relation.last_seen_at` | string (date-time) or null |  |
| `relation.note` | string or null | One line the model wrote about them. **Replaced, not appended** — it is rendered into every future prompt for this pair, so it is bounded by design. |
| `relation.recorded` | integer | How many times the model has recorded a change. Zero on a long history means the tool is not being called, which is the first thing to check when a relationship is not moving. |
| `relation.summary` | string | The relationship as the model is told it — bands and counted phrases, never numbers. **Empty string** when there is nothing worth saying yet, in which case nothing about them is added to the prompt at all — which is a real state and not a missing value. |
| `relation.tier` | `"Despised"`, `"Disliked"`, `"Acquaintances"`, `"Friends"`, `"Good Friends"` | What the agent would call this relationship, and what it is actually told: **Despised, Disliked, Acquaintances, Friends** or **Good Friends**. Derived on every read from the axes below and never stored, so it cannot go stale. The rule is one sentence: *warmth and trust set the direction, familiarity sets how far it can go* — a first conversation caps at Acquaintances however well it went, and the ceiling rises as they talk. Dislike is not capped: an agent can take against somebody on one remark. |
| `relation.trust` | number | -1 to 1, on the same scale. |
| `relation.turns` | integer | Turns exchanged, ever. Counted, not judged. |
| `relation.warmth` | number | -1 to 1. Zero is indifference, which is where every relationship starts. |
| `resumed` | boolean | True when this is a conversation that already existed rather than a new one — see `resume` on the request. Worth branching on: a resumed session arrives with its `messages` already populated and its `turn_seq` past zero, so a client that assumed an empty transcript will render nothing until the next turn. |

## Delete a conversation

`DELETE /v1/agent/sessions/{session_id}`

Deletes every message in it too. This cannot be undone.

```bash title="curl"
curl -X DELETE "https://api.lumavox.ai/v1/agent/sessions/$SESSION_ID" \
  -H "Authorization: Bearer $EPHEMERAL_TOKEN"
```

**Path parameters**

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

**Responses**

| Status | Description |
|---|---|
| `204` | The session and its messages are gone |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability |
| `404` | Unknown session id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

## Compact a conversation

`POST /v1/agent/sessions/{session_id}/compact`

Summarises everything said so far but the last few exchanges and starts the next turn from that summary, then those exchanges word for word, instead of the full history. How many messages stay is the platform's `agents.memory.compaction.keep_messages`; a conversation no longer than that is summarised whole.

The transcript is **not** changed — `GET /v1/agent/sessions/{id}` still returns every message. What changes is only what the model is sent.

This is a model call, charged to the key. Agents can also do it automatically by setting a token threshold.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/agent/sessions/$SESSION_ID/compact" \
  -H "Authorization: Bearer $EPHEMERAL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Path parameters**

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

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

| Field | Type | Required | Description |
|---|---|---|---|
| `extract_facts` | boolean or null |  | Whether to also extract durable facts about the end user, in the same model call. Unset uses the agent's own setting. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — The summary that replaced the turns it covers |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `402` | The key has spent its credit — mint another; nothing was charged |
| `403` | The key is valid but its scopes do not cover this capability |
| `404` | Unknown session id |
| `409` | Another compaction of this conversation finished first |
| `422` | Nothing new to compact since the last compaction, or the model's summary was empty or no shorter than what it would replace; nothing was charged |
| `429` | Rate limit exceeded |
| `502` | The model provider failed or timed out |
| `503` | The model provider is not configured |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `after_seq` | integer | The last message this summary covers. Anything after it — the last few exchanges, kept word for word, then whatever is said next — is still sent to the model verbatim. |
| `facts_stored` | integer | Durable facts written to long-term memory by this call. Zero when extraction was not asked for, or when there was nothing worth keeping. |
| `input_tokens` | integer or null |  |
| `model` | string or null | What produced the summary. |
| `output_tokens` | integer or null |  |
| `session_id` | string |  |
| `summary` | string | What the next turn will start from. |
| `trigger` | `"manual"`, `"automatic"`, `"idle"` | `manual` (a client asked) or `automatic` (a turn reached the agent's threshold, or a long compaction's next pass). `idle` is no longer written: it marks compactions made when a quiet conversation's facts were read, before facts were read on their own. |

## Extract what is worth remembering

`POST /v1/agent/sessions/{session_id}/extract`

Compacts the conversation **and** extracts durable facts about the user into long-term memory, in one model call, charged to the key.

Identical to `POST /sessions/{id}/compact` with `{"extract_facts": true}` — it exists because remembering and shortening are different intentions even though they are one request.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/agent/sessions/$SESSION_ID/extract" \
  -H "Authorization: Bearer $EPHEMERAL_TOKEN"
```

**Path parameters**

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

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — The facts pulled out of the conversation and stored |
| `401` | Missing or invalid credentials |
| `402` | The key has spent its credit — mint another; nothing was charged |
| `403` | The key is valid but its scopes do not cover this capability |
| `404` | Unknown session id |
| `409` | Another compaction of this conversation finished first |
| `422` | Nothing new to compact since the last compaction, or the model's summary was empty or no shorter than what it would replace; nothing was charged |
| `429` | Rate limit exceeded |
| `502` | The model provider failed or timed out |
| `503` | The model provider is not configured |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `after_seq` | integer | The last message this summary covers. Anything after it — the last few exchanges, kept word for word, then whatever is said next — is still sent to the model verbatim. |
| `facts_stored` | integer | Durable facts written to long-term memory by this call. Zero when extraction was not asked for, or when there was nothing worth keeping. |
| `input_tokens` | integer or null |  |
| `model` | string or null | What produced the summary. |
| `output_tokens` | integer or null |  |
| `session_id` | string |  |
| `summary` | string | What the next turn will start from. |
| `trigger` | `"manual"`, `"automatic"`, `"idle"` | `manual` (a client asked) or `automatic` (a turn reached the agent's threshold, or a long compaction's next pass). `idle` is no longer written: it marks compactions made when a quiet conversation's facts were read, before facts were read on their own. |

## What is remembered about an end user

`GET /v1/agent/facts`

What is stored about the key's end user, **oldest first**, a page at a time — up to 200 a page, 100 by default. A fact learnt while you page lands on the last page.

Facts are shared across every agent you run — the same person talking to a different character is still the same person. `agent_id` records which one was being talked to when a fact was learned.

A fact a later conversation changed or contradicted is **replaced**: hidden from the agents and from this list, and deleted the platform's `agents.memory.facts.replaced_kept_days` later (`GET /v1/platform/limits`). `include_replaced=true` lists those too, each with `replaced_at`, and `replaced_by` naming the fact that took its place.

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

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `end_user_id` | string |  | Optional, and only ever the key's own end user — their `eu_...` id or your own id for them. Anybody else is a 403. |
| `include_replaced` | boolean |  | Also list the facts a later conversation replaced or removed, until they are deleted, the platform's `agents.memory.facts.replaced_kept_days` later (`GET /v1/platform/limits`). Default `false`. |
| `limit` | integer |  | How many to answer, 1 to 200. 1–200. Default `100`. |
| `cursor` | string |  | The `next_cursor` of the page before. At least 1 characters. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — What the platform remembers about this end user |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `data` | array of FactInfo |  |
| `data[].agent_id` | string or null | Which agent was being talked to when this was learned. |
| `data[].confirmed_at` | string (date-time) or null | When a conversation last said it again; `null` for never. The agents see the most recently confirmed first when not every fact fits. |
| `data[].created_at` | string (date-time) |  |
| `data[].id` | string |  |
| `data[].replaced_at` | string (date-time) or null | When a later conversation replaced or removed it; `null` while it stands. Listed only with `include_replaced=true`. |
| `data[].replaced_by` | string or null | The fact that replaced it — moved city, changed job — or `null` when it was removed with nothing in its place, or stands. |
| `data[].scope` | `"person"`, `"agent"` | `person`: recalled by every agent of yours that shares its facts. `agent`: kept to one agent (`agent_id`), which alone recalls it — learnt by an agent whose `facts_scope` is `agent`, or added for that agent. |
| `data[].source` | `"conversation"`, `"customer"` | `conversation`: a reading learnt it. `customer`: you added or corrected it — a later conversation never replaces or removes it. |
| `data[].text` | string |  |
| `data[].updated_at` | string (date-time) | When it last changed: its `replaced_at` once replaced, else when you last corrected it, else its `created_at`. |
| `next_cursor` | string or null | Pass as `cursor` for the next page; `null` when this is the last. |

## Remember something about the end user

`POST /v1/agent/facts`

Adds a fact about the key's end user, as you state it, for the agents to know from their next turn: what your app already knows and wants them to keep — a name, a preference.

**It is yours**: a later conversation confirms it but never replaces or removes it, and its `source` is `customer`. With `agent_id` only that agent recalls it; without, every agent of yours does. Free.

**Idempotent by its words**: a fact that already says the same thing is answered `200` — now yours — and a new one `201`. A fact forgotten before is remembered again.

```bash title="curl"
curl -X POST "https://api.lumavox.ai/v1/agent/facts" \
  -H "Authorization: Bearer $EPHEMERAL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text":"Prefers to be called Sam."}'
```

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

| Field | Type | Required | Description |
|---|---|---|---|
| `text` | string | yes | What to remember about the person, as one plain statement: "Prefers to be called Sam." At most 500 characters. Never a secret — a password, a code, a card, account or identity number, or a key is refused, since facts are sent to the model on every turn. 1–500 characters. |
| `agent_id` | string or null |  | Keep it to one agent: only that agent recalls it. Unset or `null`: every agent of yours that shares its facts — and the ones that keep their own, too. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — The fact that already said it, now yours |
| `201` | `application/json` — The fact, remembered |
| `400` | The request could not be read: malformed JSON |
| `401` | Missing or invalid credentials |
| `403` | The key's scopes do not cover this, or it is pinned to agents and `agent_id` names another |
| `422` | No text, or text that looks like a secret — a password, a code, a card, account or identity number, or a key — which no fact may hold |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `agent_id` | string or null | Which agent was being talked to when this was learned. |
| `confirmed_at` | string (date-time) or null | When a conversation last said it again; `null` for never. The agents see the most recently confirmed first when not every fact fits. |
| `created_at` | string (date-time) |  |
| `id` | string |  |
| `replaced_at` | string (date-time) or null | When a later conversation replaced or removed it; `null` while it stands. Listed only with `include_replaced=true`. |
| `replaced_by` | string or null | The fact that replaced it — moved city, changed job — or `null` when it was removed with nothing in its place, or stands. |
| `scope` | `"person"`, `"agent"` | `person`: recalled by every agent of yours that shares its facts. `agent`: kept to one agent (`agent_id`), which alone recalls it — learnt by an agent whose `facts_scope` is `agent`, or added for that agent. |
| `source` | `"conversation"`, `"customer"` | `conversation`: a reading learnt it. `customer`: you added or corrected it — a later conversation never replaces or removes it. |
| `text` | string |  |
| `updated_at` | string (date-time) | When it last changed: its `replaced_at` once replaced, else when you last corrected it, else its `created_at`. |

## Forget an end user entirely

`DELETE /v1/agent/facts`

Deletes everything remembered about the key's end user. Cannot be undone.

It leaves their conversations and the end user itself alone. `DELETE /v1/end-users/{id}` removes all three, and is what to call when one of your users asks to be forgotten outright.

```bash title="curl"
curl -X DELETE "https://api.lumavox.ai/v1/agent/facts" \
  -H "Authorization: Bearer $EPHEMERAL_TOKEN"
```

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `end_user_id` | string |  | Optional, and only ever the key's own end user — their `eu_...` id or your own id for them. Anybody else is a 403. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — How many stored facts were deleted |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `facts_deleted` | integer | How many stored facts were deleted. |

## Correct what is remembered

`PATCH /v1/agent/facts/{fact_id}`

Rewrites one fact about the key's end user. It becomes yours (`source` `customer`), so a later conversation never replaces it, and **the old words are kept as forgotten**: no reading of what was said before brings them back.

A fact a later conversation already replaced is a 409 — correct the one that replaced it — and so are words another fact already says. Free.

```bash title="curl"
curl -X PATCH "https://api.lumavox.ai/v1/agent/facts/$FACT_ID" \
  -H "Authorization: Bearer $EPHEMERAL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text":"Prefers to be called Sam."}'
```

**Path parameters**

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

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

| Field | Type | Required | Description |
|---|---|---|---|
| `text` | string | yes | What to remember about the person, as one plain statement: "Prefers to be called Sam." At most 500 characters. Never a secret — a password, a code, a card, account or identity number, or a key is refused, since facts are sent to the model on every turn. 1–500 characters. |

**Responses**

| Status | Description |
|---|---|
| `200` | `application/json` — The fact, corrected |
| `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 |
| `404` | Unknown fact id |
| `409` | Replaced by a later conversation, or another fact already says that |
| `422` | No text, or text that looks like a secret — a password, a code, a card, account or identity number, or a key — which no fact may hold |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `agent_id` | string or null | Which agent was being talked to when this was learned. |
| `confirmed_at` | string (date-time) or null | When a conversation last said it again; `null` for never. The agents see the most recently confirmed first when not every fact fits. |
| `created_at` | string (date-time) |  |
| `id` | string |  |
| `replaced_at` | string (date-time) or null | When a later conversation replaced or removed it; `null` while it stands. Listed only with `include_replaced=true`. |
| `replaced_by` | string or null | The fact that replaced it — moved city, changed job — or `null` when it was removed with nothing in its place, or stands. |
| `scope` | `"person"`, `"agent"` | `person`: recalled by every agent of yours that shares its facts. `agent`: kept to one agent (`agent_id`), which alone recalls it — learnt by an agent whose `facts_scope` is `agent`, or added for that agent. |
| `source` | `"conversation"`, `"customer"` | `conversation`: a reading learnt it. `customer`: you added or corrected it — a later conversation never replaces or removes it. |
| `text` | string |  |
| `updated_at` | string (date-time) | When it last changed: its `replaced_at` once replaced, else when you last corrected it, else its `created_at`. |

## Forget one fact

`DELETE /v1/agent/facts/{fact_id}`

Deletes a single memory of the key's end user. Cannot be undone.

```bash title="curl"
curl -X DELETE "https://api.lumavox.ai/v1/agent/facts/$FACT_ID" \
  -H "Authorization: Bearer $EPHEMERAL_TOKEN"
```

**Path parameters**

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

**Responses**

| Status | Description |
|---|---|
| `204` | The fact is gone |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability |
| `404` | Unknown fact id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

## How the agents feel about an end user

`GET /v1/agent/relations`

One row per agent the key's end user has talked to that keeps a relationship, most recently active first — or, with `agent_id`, that agent's row alone.

`warmth` and `trust` are moved by the agent itself when something in a conversation changes its view; `turns` and `conversations` are counted. `summary` is the sentence the model is actually given.

**Per end user**: each person's relationships live with that person, so one call reads one person's.

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

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `agent_id` | string |  | Only this agent's relationship. |
| `end_user_id` | string |  | Optional, and only ever the key's own end user — their `eu_...` id or your own id for them. Anybody else is a 403. |
| `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` — How each agent feels about the person, most recent first |
| `401` | Missing or invalid credentials |
| `403` | The key is valid but its scopes do not cover this capability |
| `404` | Unknown agent id |
| `422` | The request failed validation |
| `429` | Rate limit exceeded |

**`200` response body**

| Field | Type | Description |
|---|---|---|
| `data` | array of RelationInfo |  |
| `data[].agent_id` | string |  |
| `data[].agent_name` | string or null | The agent's name, so a list of these is readable without resolving ids. |
| `data[].conversations` | integer | Separate sessions, ever. A stronger signal of familiarity than `turns` — twenty short visits is a longer acquaintance than one long conversation. |
| `data[].disclosure` | number | How much the agent has told **them** about itself, -1 to 1 — the asymmetric half of knowing each other, and the one nothing can count. Deliberately not part of `tier`: an agent can have opened up to somebody it has come to dislike, and folding this into affection would make that state unrepresentable. |
| `data[].end_user_id` | string | The person, as `eu_...`. |
| `data[].first_met_at` | string (date-time) or null |  |
| `data[].last_seen_at` | string (date-time) or null |  |
| `data[].note` | string or null | One line the model wrote about them. **Replaced, not appended** — it is rendered into every future prompt for this pair, so it is bounded by design. |
| `data[].recorded` | integer | How many times the model has recorded a change. Zero on a long history means the tool is not being called, which is the first thing to check when a relationship is not moving. |
| `data[].summary` | string | The relationship as the model is told it — bands and counted phrases, never numbers. **Empty string** when there is nothing worth saying yet, in which case nothing about them is added to the prompt at all — which is a real state and not a missing value. |
| `data[].tier` | `"Despised"`, `"Disliked"`, `"Acquaintances"`, `"Friends"`, `"Good Friends"` | What the agent would call this relationship, and what it is actually told: **Despised, Disliked, Acquaintances, Friends** or **Good Friends**. Derived on every read from the axes below and never stored, so it cannot go stale. The rule is one sentence: *warmth and trust set the direction, familiarity sets how far it can go* — a first conversation caps at Acquaintances however well it went, and the ceiling rises as they talk. Dislike is not capped: an agent can take against somebody on one remark. |
| `data[].trust` | number | -1 to 1, on the same scale. |
| `data[].turns` | integer | Turns exchanged, ever. Counted, not judged. |
| `data[].warmth` | number | -1 to 1. Zero is indifference, which is where every relationship starts. |
| `next_cursor` | string or null | Pass as `cursor` for the next page; `null` when this is the last. |
