Skip to navigation

Conversation history in an embedded widget

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.
  • 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:

{
"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 to request the history capability. Use the returned visitor secret for later history requests:

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:

{
"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.

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.

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:

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

The following table describes the conversation-title settings:

FieldDefaultDescription
enabledfalseRequires an explicit true value.
modelInherited surface model or account defaultUses this model when set. If omitted, uses the model that ran the surface, then the account default.
fallbackModelsEmptyLists additional models to try after the primary model fails. The service tries at most two models per turn.
promptBuilt-in promptReplaces 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
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 parameterRequiredDescription
sessionIdYesLive session from /v1/client/init.
targetIdNoReturns only conversations for one flow or agent.
flowIdNoDeprecated alias of targetId.
cursorNoOpaque cursor from the previous page.
limitNoDefaults 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:

FieldDescription
idConversation ID. Pass it as conversationId to reopen the conversation.
titleDisplay title. See Conversation titles.
targetIdFlow or agent that the conversation ran against. null for a conversation stored without one.
flowIdDeprecated alias of targetId with the same value.
previewPlain-text excerpt of the most recent visitor-visible message, up to 140 Unicode code points. null when that message has no text.
messageCountNumber of stored messages.
createdAtISO 8601 timestamp of when the conversation was created.
updatedAtISO 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 parameterRequiredDescription
sessionIdYesLive session from /v1/client/init.
messageCursorNoThe previous response’s nextMessageCursor. Omit it for the newest page.
messageLimitNoPage 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:

FieldMeaning
displayContentThe display projection. Present only when the client recorded one. An empty string means that the message has no visible text.
contentThe content that the model received. The API omits it when no display projection exists and the content is not plainly renderable.
displayAvailableIndicates 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
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
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
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.

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
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
{
"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
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.

Identity Exchange admission must be enabled for your organization. For more information, see 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:

ValueMeaning
not_providedNo proof accompanied the request. The request uses browser scope.
admittedThe API verified the proof and used the end-user scope.
ignoredThe 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.

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.

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:

Statuserror codeMeaning
400invalid_target_filter, conflicting_target_filterInvalid input, missing session, malformed cursor, or an empty or conflicting targetId and flowId filter.
401visitor_requiredA conversationId reopen without a usable visitorToken or identityProof.
401invalid_identity_proof, visitor_identity_mismatchThe identity proof was rejected, or it admits a different end user than the visitor’s binding.
401No stable codeExpired session or unusable visitor secret.
403visitor_history_disabledHistory is disabled by surface policy, or the session did not request history at init.
403No stable codeInactive token or origin mismatch.
404No stable codeNo such conversation in the authorized visitor scope.
409conversation_modifiedThe conversation changed during display-projection finalization. Nothing was stored.
410conversation_deletedA chat request for a deleted conversation. Initialize a new session.
429No stable codeRate limited. Respect Retry-After.
503identity_proof_not_admittedYour 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: