> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.runtype.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.runtype.com/_mcp/server.

# Conversation history in an embedded widget

> Show a visitor their own past conversations with the public client API, with no backend on your site.

You can show each visitor their own conversation history in an embedded chat without adding a host-site backend or database. This guide covers the public `/v1/client/*` API that the chat widget and custom clients use. History is off unless your client requests it at initialization.

A management API key authenticates the management API, including the records API. Never ship it to a browser. Use the public `/v1/client/*` routes with a client token instead.

## Before you begin

Make sure that you have the following:

* A chat product surface. For more information, see [Setting up a chat surface](/user-guide/products-surfaces/setting-up-a-chat-surface).
* A client token for that surface, with `allowedOrigins` set to the sites that host your chat.

## Enable and initialize history

Configure `behavior.conversationHistory` on a chat product surface:

```json
{
  "type": "chat",
  "conversationHistory": {
    "enabled": true,
    "visitorIdleDays": 90
  }
}
```

Set `enabled` to `false` to disable history. When the configuration block is present, `enabled` defaults to `true`.

`visitorIdleDays` sets how long a browser can go unused before it loses access to its history. It defaults to 90 and clamps values to 1 to 365 days. Each successful history-capable request renews the window, and `visitor.expiresAt` in the init response shows the current expiry. After the window expires, the browser starts over with empty history.

Send `visitorHistory: true` to [`POST /v1/client/init`](/api-reference/runtype-api/client-runtime/initialize-a-client-session) to request the history capability. Use the returned visitor secret for later history requests:

**`TypeScript`**

```typescript title="TypeScript"
const tokenBytes = new TextEncoder().encode(CLIENT_TOKEN)
const tokenHash = new Uint8Array(
  await crypto.subtle.digest('SHA-256', tokenBytes)
)
const storageKey = `runtype:visitor:${Array.from(tokenHash, (byte) =>
  byte.toString(16).padStart(2, '0')
).join('')}`
const stored = localStorage.getItem(storageKey) ?? undefined

const response = await fetch('https://api.runtype.com/v1/client/init', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    token: CLIENT_TOKEN,
    visitorHistory: true,
    visitorToken: stored,
  }),
})
const {
  sessionId,
  conversationId,
  conversationRevision,
  targetId,
  visitor,
} = await response.json()

if (visitor?.token) {
  localStorage.setItem(storageKey, visitor.token)
}
```

Replace `CLIENT_TOKEN` with the client token for the embedded surface.

The response includes these history fields:

```json
{
  "sessionId": "cs_01jq8z6k2re9v0w1m3n5p7s9t2",
  "conversationId": "record_01jq8z6k2re9v0w1m3n5p7s9t2",
  "conversationRevision": "rev_2f8c1a44-9d3e-4d21-a0b7-5c9e1f0a2b34",
  "targetId": "flow_01jq8z6k2re9v0w1m3n5p7s9t2",
  "expiresAt": "2026-08-07T18:30:00.000Z",
  "visitor": {
    "id": "cvis_01jq8z6k2re9v0w1m3n5p7s9t2",
    "token": "cvt_9Fk2pQ7xR4mN8sT1vW3yZ6bC5dE0gH2jK4lM7nP9qS",
    "expiresAt": "2026-11-05T18:30:00.000Z",
    "endUserId": null,
    "identityStatus": "not_provided"
  }
}
```

Persist `conversationId` instead of `sessionId`. A session is short-lived, but the conversation is what a returning browser reopens. Pass the returned `targetId` to the history list and delete filters.

An agent-only client token stores conversations under the agent ID, which is the `targetId` the init response returns. `conversationRevision` is an opaque change token. For details, see [Detect changes from another device](#detect-changes-from-another-device).

A data-only Runtype App returns `app` instead of `flow` and omits `targetId`. It is not chat-capable, so it has no conversation history to list.

The server returns the raw visitor secret only when it mints the visitor. It stores only a SHA-256 hash and scopes the secret to one client token. Use a client-token-specific browser storage key so two widgets on the same origin do not share a secret.

> **Warning**
>
> Save a newly returned `visitor.token` before doing other work. The server cannot recover it. If
> you lose the token or it expires, a history-capable init without a usable token starts a new
> anonymous visitor with empty history.

## Conversation titles

Runtype creates a conversation record at session init, before it stores a message. The stored `name` starts as a label built from the session ID and creation time, such as `cs_01jq8z6k2re9v0w1m3n5p7s9t2 - Aug 7, 2026, 6:30 PM`. The history list returns that value as `title`.

Set `behavior.conversationTitles.enabled` to `true` to generate a title from the opening exchange. Conversation titles must also be available for your organization. If titles do not appear, contact Runtype.

The following surface configuration turns on title generation:

```json
{
  "type": "chat",
  "conversationTitles": {
    "enabled": true
  }
}
```

The following table describes the conversation-title settings:

| Field            | Default                                    | Description                                                                                                  |
| ---------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `enabled`        | `false`                                    | Requires an explicit `true` value.                                                                           |
| `model`          | Inherited surface model or account default | Uses this model when set. If omitted, uses the model that ran the surface, then the account default.         |
| `fallbackModels` | Empty                                      | Lists additional models to try after the primary model fails. The service tries at most two models per turn. |
| `prompt`         | Built-in prompt                            | Replaces the built-in titling prompt.                                                                        |

If no model resolves, the conversation is not titled. Runtype does not substitute a platform-selected model.

Title generation is opt-in because it sends the opening visitor and assistant messages to a model in a separate call.

A title is generated only for a history-capable session. Request history with `visitorHistory: true`, or provide `visitorToken`, `identityProof`, or `conversationId` at init. A surface with `piiRedaction: "redact"` is not titled because title generation reads the stored, unredacted messages.

Title generation does not block or fail a chat turn. After it succeeds, it does not replace a title that you set through the API or dashboard. If all configured models fail, the timestamp label remains and later turns can retry while the conversation has 10 or fewer stored messages.

The setting applies to the surface that owns the client token. A token that is not associated with a surface does not have title configuration, so its conversations are not titled.

## List and read conversations

Use a live `sessionId` in the query string and the visitor secret in `X-Visitor-Token` for every history request. Neither a public client token nor a session ID authorizes history by itself.

List one page of conversations with this request:

**`TypeScript`**

```typescript title="TypeScript"
const params = new URLSearchParams({ sessionId, limit: '25' })
const response = await fetch(
  `https://api.runtype.com/v1/client/conversations?${params}`,
  { headers: { 'X-Visitor-Token': visitorToken } }
)
const { data, nextCursor } = await response.json()
```

The following table lists the query parameters for the conversation list:

| Query parameter | Required | Description                                       |
| --------------- | -------- | ------------------------------------------------- |
| `sessionId`     | Yes      | Live session from `/v1/client/init`.              |
| `targetId`      | No       | Returns only conversations for one flow or agent. |
| `flowId`        | No       | Deprecated alias of `targetId`.                   |
| `cursor`        | No       | Opaque cursor from the previous page.             |
| `limit`         | No       | Defaults to 25 and accepts a maximum of 100.      |

If you send different values for `targetId` and `flowId`, the API returns `400` with `conflicting_target_filter`. It accepts both parameters when their values match.

The list includes only conversations with stored messages. It sorts results by `updatedAt`, then by ID, with the newest result first. Follow `nextCursor` until it is `null`.

The following table describes the fields in each conversation summary:

| Field          | Description                                                                                                                         |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `id`           | Conversation ID. Pass it as `conversationId` to reopen the conversation.                                                            |
| `title`        | Display title. See [Conversation titles](#conversation-titles).                                                                     |
| `targetId`     | Flow or agent that the conversation ran against. `null` for a conversation stored without one.                                      |
| `flowId`       | Deprecated alias of `targetId` with the same value.                                                                                 |
| `preview`      | Plain-text excerpt of the most recent visitor-visible message, up to 140 Unicode code points. `null` when that message has no text. |
| `messageCount` | Number of stored messages.                                                                                                          |
| `createdAt`    | ISO 8601 timestamp of when the conversation was created.                                                                            |
| `updatedAt`    | ISO 8601 timestamp of the most recent write. The list sorts by this field.                                                          |

Call `GET /v1/client/conversations/{id}` to read the newest page of a transcript. The following table lists its query parameters:

| Query parameter | Required | Description                                                               |
| --------------- | -------- | ------------------------------------------------------------------------- |
| `sessionId`     | Yes      | Live session from `/v1/client/init`.                                      |
| `messageCursor` | No       | The previous response's `nextMessageCursor`. Omit it for the newest page. |
| `messageLimit`  | No       | Page size. Defaults to 25, which is also the maximum.                     |

The response contains the summary fields, plus `messages`, `nextMessageCursor`, and `conversationRevision`. Messages are oldest first within each page. To load the next older page, pass the previous response's `nextMessageCursor` as `messageCursor`. The cursor is `null` when no older messages exist.

## Render what the visitor saw, not what the model read

Each history message has two text fields. `content` is what the model received. `displayContent`, the display projection, is the text the visitor saw after your client rendered it. `content` can contain structured payloads, hidden variants, or directives that the visitor did not see, so do not assume that it is safe to display.

The following table describes the fields in a history message:

| Field              | Meaning                                                                                                                            |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| `displayContent`   | The display projection. Present only when the client recorded one. An empty string means that the message has no visible text.     |
| `content`          | The content that the model received. The API omits it when no display projection exists and the content is not plainly renderable. |
| `displayAvailable` | Indicates whether the message has any renderable form. When `false`, render a placeholder instead of deriving text from `content`. |

When you render a message, use `displayContent` when it is present. Otherwise, use `content` only when `displayAvailable` is `true`. Do not treat either field as model input.

To record a display projection, send `displayContent` alongside `content` in a `/v1/client/chat` message. This covers visitor messages and assistant text that you know before you send the request.

### Finalize the last assistant turn

Every `/v1/client/chat` and `/v1/client/resume` turn stores the assistant reply for you. To address that reply later, generate an ID and send it as `assistantMessageId` on the chat or resume request. If you omit it, Runtype assigns its own ID, and a display-projections request that names your client's ID returns `404`.

The following chat request names the assistant reply:

**`TypeScript`**

```typescript title="TypeScript"
const assistantMessageId = `msg_${crypto.randomUUID()}`

await fetch('https://api.runtype.com/v1/client/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    sessionId,
    assistantMessageId,
    messages: [{ id: userMessageId, role: 'user', content: userText }],
  }),
})
```

Replace `userMessageId` with your ID for the visitor message and `userText` with the visitor's text.

After your renderer finishes the assistant stream, send the final visible text to the display-projections endpoint with the same ID:

**`TypeScript`**

```typescript title="TypeScript"
await fetch(
  `https://api.runtype.com/v1/client/conversations/${conversationId}` +
    `/display-projections?sessionId=${sessionId}`,
  {
    method: 'PATCH',
    keepalive: true,
    headers: {
      'Content-Type': 'application/json',
      'X-Visitor-Token': visitorToken,
    },
    body: JSON.stringify({
      messages: [{ id: assistantMessageId, displayContent: renderedText }],
    }),
  }
)
```

The API returns a `conversationRevision` field.

The endpoint only fills or replaces `displayContent`. It does not create a message or change its model content, role, ordering, timestamps, message count, ownership, or title. The batch applies atomically, and a revision guard prevents it from overwriting a chat turn that the API stores at the same time.

You can replay the same display projection. The API performs no write and returns the same revision when the value is unchanged.

If the endpoint returns `409`, the conversation changed during the write and nothing was stored. Retry the request, or let the next chat turn carry the display projection.

A changed display projection refreshes the list `preview` but does not change `updatedAt`. Finalizing a display projection therefore does not reorder history.

The endpoint returns `404` when the live session is not bound to the conversation or the visitor does not own it. It also returns `404` when the batch names a message that the conversation does not store. One unknown message ID fails the whole batch.

The endpoint accepts up to 50 messages per batch, limits each `displayContent` value to 32768 characters, and limits the batch to 49152 characters. These limits keep a page-exit request within the browser `keepalive` budget.

If a finalization is lost, the next chat turn repairs it: re-send the message with its `displayContent`. The API uses only the display projection for an existing message and keeps its stored model content.

## Detect changes from another device

Compare `conversationRevision` for equality only. The token is opaque, unordered, and not parseable.

The token changes on every transcript change, including a display-projection finalization that leaves `updatedAt` unchanged. Use it to detect whether a cached transcript is current without reordering the history list.

When you send `/v1/client/chat`, the API runs the stored transcript and merges in only message IDs that are new in your request. Turns from another device remain in the conversation context, and stored content wins for an ID that already exists.

## Reopen a conversation

Use the `id` of a listed conversation as `conversationId` on a new init request. Do not maintain a browser-local conversation-to-session map.

Reopen a listed conversation with this request:

**`TypeScript`**

```typescript title="TypeScript"
const response = await fetch('https://api.runtype.com/v1/client/init', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    token: CLIENT_TOKEN,
    visitorHistory: true,
    visitorToken,
    conversationId: selectedConversation.id,
  }),
})
```

`conversationId` and legacy `sessionId` are mutually exclusive. The stored conversation target is authoritative, so a resume request cannot switch the conversation to another flow or agent. If you send `conversationId` without a usable `visitorToken` or `identityProof`, init returns `401` with `visitor_required`. Missing, deleted, disallowed, and out-of-scope conversations return `404`.

> **Warning**
>
> Treat `sessionId` as a value that the API issues. Do not accept it from a URL, query string, or
> other third-party input. An attacker can use an unowned session ID to make this visitor resume the
> attacker's conversation. Use `conversationId` for cross-session or cross-device resume because the
> API authorizes it with the visitor secret.

## Delete or reset

Delete one conversation with `DELETE /v1/client/conversations/{id}`, or delete all conversations with `DELETE /v1/client/conversations`. Pass `sessionId` in the query string and `X-Visitor-Token` in the header for both requests. Both return `{ "deleted": N }` with the number of deleted conversations. Delete-all returns `0`, not an error, when the visitor has no history.

For a target-scoped history UI, pass `targetId` to bulk delete. Omit `targetId` to delete every conversation in the authorized visitor scope across all targets. Deletion removes stored transcript assets and the conversation record. If the deleted conversation backs the active chat session, initialize a new session before sending another message. The chat endpoint returns `410 conversation_deleted` instead of running a turn that it cannot persist.

To revoke the visitor during widget shutdown, logout, or a "forget this browser" action, call the reset endpoint:

**`TypeScript`**

```typescript title="TypeScript"
await fetch(
  `https://api.runtype.com/v1/client/visitor/reset?sessionId=${sessionId}`,
  {
    method: 'POST',
    headers: { 'X-Visitor-Token': visitorToken },
  }
)
localStorage.removeItem(storageKey)
```

Reset is idempotent and returns `{ "reset": true }`, even when the secret is expired or already revoked. A later history-capable init mints a clean visitor.

## Verified cross-device history

Pass a registered identity proof as `identityProof` at init to bind the browser visitor to an admitted `eu_*` end user. If another person signs in on an already-bound browser, the API mints a new visitor instead of giving that person the existing history.

`identityProof` takes either form:

* A string that holds the proof token. Runtype selects the identity integration by the token's verified issuer.
* An object with `provider` and `token`, the same form that chat and dispatch requests take. Set `provider` to the integration's provider value, for example `clerk`. If the token verifies against an integration with a different provider value, init returns an `invalid_identity_proof` error whose `errorDescription` is `provider_hint_mismatch`.

**`Init body`**

```json title="Init body"
{
  "token": "YOUR_CLIENT_TOKEN",
  "visitorHistory": true,
  "identityProof": { "provider": "clerk", "token": "YOUR_IDENTITY_PROOF" }
}
```

Replace `YOUR_CLIENT_TOKEN` with the surface's client token and `YOUR_IDENTITY_PROOF` with a fresh token from your identity provider. Check `visitor.identityStatus` in the response: `admitted` means Runtype verified the proof, and `ignored` means Identity Exchange admission is not enabled for your organization.

The binding is durable, but it does not authorize sibling-device history by itself. To widen list, read, or delete scope beyond the exact browser, send a fresh proof on each request:

**`TypeScript`**

```typescript title="TypeScript"
const headers = {
  'X-Visitor-Token': visitorToken,
  'X-Identity-Proof': await getFreshIdentityProof(),
}
```

Use `identityProof` in the init body for `conversationId` resume. The admitted identity must match the visitor's stored binding. Without a fresh proof, a bound visitor can read only conversations owned by that browser.

> **Warning**
>
> Identity Exchange admission must be enabled for your organization. For more information, see
> [Pass a proof on a request](/developer-guides/guides/end-user-identity#pass-a-proof-on-a-request).
> With Identity Exchange admission enabled, invalid, expired, cross-tenant, or mismatched proofs
> fail closed with `401`. The API does not rebind the visitor or return sibling history.

### Know which scope you received

The API evaluates a proof only when Identity Exchange admission is enabled for your organization. Without Identity Exchange admission, init ignores a supplied proof and uses browser scope. A history request that includes that proof returns `503` with `identity_proof_not_admitted` before it reads or deletes data.

Init reports the result in the visitor grant. Every history route reports it in the CORS-exposed `X-History-Identity-Status` response header:

| Value          | Meaning                                                                                                    |
| -------------- | ---------------------------------------------------------------------------------------------------------- |
| `not_provided` | No proof accompanied the request. The request uses browser scope.                                          |
| `admitted`     | The API verified the proof and used the end-user scope.                                                    |
| `ignored`      | The request included a proof, but Identity Exchange admission is disabled, so the API did not evaluate it. |

Init succeeds with `ignored` so ordinary chat continues. Do not offer verified cross-device history or verified erase in that state. A history route with the same proof returns `503` with `identity_proof_not_admitted`. Retry without the proof to act at browser scope.

These statuses report the request outcome. They do not grant authority or carry identity information.

## Mobile, native, and custom clients

Use the same public HTTP and SSE API from a mobile app, desktop app, or custom web client. The chat widget is the reference client, not a requirement.

For a custom client, call `POST /v1/client/init` with the client token and `visitorHistory: true`. Store the returned `cvt_*` secret in Keychain or Keystore, call `POST /v1/client/chat` with the session ID, and read history with `sessionId` and `X-Visitor-Token`. Read `title` from each list response to display conversation titles.

Native fetch requests omit the `Origin` header. Add the literal `native` sentinel to `allowedOrigins` to admit originless requests. The sentinel does not match a request that sends `Origin: native`.

> **Warning**
>
> Do not put `native` on a token that you also ship on the web. A client token is public, so a page
> can remove the `Origin` header and use that token without origin protection. Issue separate tokens
> for web and native clients. Use real origins for web tokens and `native` for native tokens. Rely
> on the native token's capability scope, namespaces, rate limits, and end-user identity as its
> security boundary.

Bind the native visitor to an `eu_*` end user at init when you need cross-device history. For more information, see [Verified cross-device history](#verified-cross-device-history).

## Errors and recovery

An error response body contains an `error` field. Branch on the stable codes in the following table. Other `error` values are readable messages that can change. Some responses also carry a `hint` or `errorDescription` field that explains the cause.

The following table lists the common history responses:

| Status | `error` code                                          | Meaning                                                                                                      |
| ------ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `400`  | `invalid_target_filter`, `conflicting_target_filter`  | Invalid input, missing session, malformed cursor, or an empty or conflicting `targetId` and `flowId` filter. |
| `401`  | `visitor_required`                                    | A `conversationId` reopen without a usable `visitorToken` or `identityProof`.                                |
| `401`  | `invalid_identity_proof`, `visitor_identity_mismatch` | The identity proof was rejected, or it admits a different end user than the visitor's binding.               |
| `401`  | No stable code                                        | Expired session or unusable visitor secret.                                                                  |
| `403`  | `visitor_history_disabled`                            | History is disabled by surface policy, or the session did not request history at init.                       |
| `403`  | No stable code                                        | Inactive token or origin mismatch.                                                                           |
| `404`  | No stable code                                        | No such conversation in the authorized visitor scope.                                                        |
| `409`  | `conversation_modified`                               | The conversation changed during display-projection finalization. Nothing was stored.                         |
| `410`  | `conversation_deleted`                                | A chat request for a deleted conversation. Initialize a new session.                                         |
| `429`  | No stable code                                        | Rate limited. Respect `Retry-After`.                                                                         |
| `503`  | `identity_proof_not_admitted`                         | Your organization cannot evaluate the supplied proof. Nothing ran.                                           |

When a history request returns `401` for an expired session or an unusable visitor secret, run one history-capable init with the stored token. If init returns a new token, replace the stored value and show empty history. Retry no more than one time to avoid a recovery loop.

When a request returns `401` with `invalid_identity_proof` or `visitor_identity_mismatch`, get a fresh proof from your identity provider. Then run a new init with that proof in `identityProof`.

## Next steps

Continue with one of these guides:

* [End-user identity and credentials](/developer-guides/guides/end-user-identity): bind history to a verified end user.
* [Client tokens and domain restrictions](/user-guide/products-surfaces/client-tokens-and-domain-restrictions): configure browser and native token origins.
* [Embedding the chat widget](/user-guide/products-surfaces/embedding-the-chat-widget-script-tag): add the embedded client.