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. History is opt-in at initialization. Older widget builds keep the existing init response and do not mint visitor credentials.

Use the public /v1/client/* API for browser history. Do not ship a management API key to a browser. That key authenticates the records API.

Enable and initialize history

Configure behavior.conversationHistory on a chat product surface:

1{
2 "type": "chat",
3 "conversationHistory": {
4 "enabled": true,
5 "visitorIdleDays": 90
6 }
7}

Set enabled to false to disable history. When the configuration block is present, enabled defaults to true. visitorIdleDays defaults to 90 and clamps values to 1 to 365 days.

Send visitorHistory: true to POST /v1/client/init to request the history capability. Use the returned visitor secret for later history requests:

TypeScript
1const tokenBytes = new TextEncoder().encode(CLIENT_TOKEN)
2const tokenHash = new Uint8Array(await crypto.subtle.digest('SHA-256', tokenBytes))
3const storageKey = `runtype:visitor:${Array.from(tokenHash, (byte) =>
4 byte.toString(16).padStart(2, '0')
5).join('')}`
6const stored = localStorage.getItem(storageKey) ?? undefined
7
8const response = await fetch('https://api.runtype.com/v1/client/init', {
9 method: 'POST',
10 headers: { 'Content-Type': 'application/json' },
11 body: JSON.stringify({
12 token: CLIENT_TOKEN,
13 visitorHistory: true,
14 visitorToken: stored,
15 }),
16})
17const { sessionId, conversationId, conversationRevision, targetId, visitor } = await response.json()
18
19if (visitor?.token) {
20 localStorage.setItem(storageKey, visitor.token)
21}

Replace CLIENT_TOKEN with the client token for the embedded surface.

The response includes these history fields:

1{
2 "sessionId": "cs_01jq8z6k2re9v0w1m3n5p7s9t2",
3 "conversationId": "record_01jq8z6k2re9v0w1m3n5p7s9t2",
4 "conversationRevision": "rev_2f8c1a44-9d3e-4d21-a0b7-5c9e1f0a2b34",
5 "targetId": "flow_01jq8z6k2re9v0w1m3n5p7s9t2",
6 "expiresAt": "2026-08-07T18:30:00.000Z",
7 "visitor": {
8 "id": "cvis_01jq8z6k2re9v0w1m3n5p7s9t2",
9 "token": "cvt_9Fk2pQ7xR4mN8sT1vW3yZ6bC5dE0gH2jK4lM7nP9qS",
10 "expiresAt": "2026-11-05T18:30:00.000Z",
11 "endUserId": null,
12 "identityStatus": "not_provided"
13 }
14}

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 can store conversations under the agent’s primary flow. That flow can differ from the ID that you configured. 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

The API creates a conversation record at session init, before it stores a message. The stored name starts as a timestamp label such as cs_01jq8z6k2r - Aug 7, 2026. The history list exposes that value as title.

Set behavior.conversationTitles.enabled to true to generate a title from the opening exchange. Account-level title generation must also be enabled:

1{
2 "type": "chat",
3 "conversationTitles": {
4 "enabled": true
5 }
6}

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 you omit model, title generation uses the model that ran the surface. If that model does not resolve, it uses your account’s default model. If neither model resolves, title generation skips the conversation.

Title generation is opt-in because it sends the opening visitor and assistant messages to a model in a separate call. The service does not use a platform-selected model when your configured or account model does not resolve.

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
1const params = new URLSearchParams({ sessionId, limit: '25' })
2const response = await fetch(`https://api.runtype.com/v1/client/conversations?${params}`, {
3 headers: { 'X-Visitor-Token': visitorToken },
4})
5const { 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.

Each summary includes preview, a plain-text excerpt of the most recent visitor-visible message. The API limits it to 140 Unicode code points and returns null when that message has no text.

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.

Call GET /v1/client/conversations/{id} to read the newest transcript page. Pass messageCursor from the previous response to load the next older page. Messages are oldest first within each page. The API rehydrates media only for the requested page and clamps messageLimit to 25.

Render what the visitor saw, not what the model read

Render the visitor-visible projection instead of assuming that content is safe to display. The content field is the model channel. It can contain structured payloads, hidden variants, or directives that the visitor did not see.

The following table describes the fields in a history message:

FieldMeaning
displayContentText that the visitor saw. The field is present only when the client recorded a projection. An empty string means that the message has no visible text.
contentStored model-channel content. The API omits it when no 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.

Send displayContent alongside content in a /v1/client/chat message to record a projection. This covers visitor messages and assistant text that you know before dispatch.

Finalize the last assistant turn

After your renderer finishes an assistant stream, send the final visible text to the projection endpoint:

TypeScript
1await fetch(
2 `https://api.runtype.com/v1/client/conversations/${conversationId}/display-projections?sessionId=${sessionId}`,
3 {
4 method: 'PATCH',
5 keepalive: true,
6 headers: {
7 'Content-Type': 'application/json',
8 'X-Visitor-Token': visitorToken,
9 },
10 body: JSON.stringify({
11 messages: [{ id: assistantMessageId, displayContent: renderedText }],
12 }),
13 }
14)

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 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 projection.

A turn that went through /v1/client/resume is the one case where the assistant message is written for you: the resumed leg’s completion is persisted server-side. Send the same id as assistantMessageId on the resume request that you later name in this call. Without it the completion is stored under a server-minted id, which your projection cannot address, and the batch answers 404.

A changed projection refreshes the list preview but does not change updatedAt. Finalizing a 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 projection for an existing message and keeps its stored model content.

Detecting 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 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 executes the stored transcript and merges in only message IDs that are new in your request. Turns from another device remain in the execution context, and stored content wins for an ID that already exists.

Reopen a conversation

Use a listed record ID 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
1const response = await fetch('https://api.runtype.com/v1/client/init', {
2 method: 'POST',
3 headers: { 'Content-Type': 'application/json' },
4 body: JSON.stringify({
5 token: CLIENT_TOKEN,
6 visitorHistory: true,
7 visitorToken,
8 conversationId: selectedConversation.id,
9 }),
10})

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

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
1await fetch(`https://api.runtype.com/v1/client/visitor/reset?sessionId=${sessionId}`, {
2 method: 'POST',
3 headers: { 'X-Visitor-Token': visitorToken },
4})
5localStorage.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.

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
1const headers = {
2 'X-Visitor-Token': visitorToken,
3 'X-Identity-Proof': await getFreshIdentityProof(),
4}

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 the account. With 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 end-user admission is enabled for the account. Without 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 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

Use the following status table to handle common history responses:

StatusMeaning
400Invalid input, missing session, or malformed cursor.
401Expired session, unusable visitor secret, or rejected identity proof.
403Inactive token, origin mismatch, or history disabled by surface policy.
404No such conversation in the authorized visitor scope.
409The conversation changed during display-projection finalization. Nothing was stored.
429Rate limited. Respect Retry-After.
503identity_proof_not_admitted: the account cannot evaluate the supplied proof. Nothing ran.

When a history request returns 401, 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.

Next steps

Continue with one of these guides: