React
@kortix/sdk/react: useSession for a whole session view, a chat kit, and data hooks for everything else.
@kortix/sdk/react is the React layer of the SDK. It gives you one hook that runs a whole session view, a headless chat kit, and data hooks for accounts, projects, sessions, secrets, triggers, Apps, notifications and access checks. It works in React and React Native.
Set up
Install
Install the SDK and its two optional peers: react 18 or later and @tanstack/react-query 5.75.2 or later.
npm install @kortix/sdk react @tanstack/react-queryCreate the client
Create the client once, at module scope. Every hook reads this client. See SDK for the options and Authentication for getToken.
// lib/kortix.ts
import { createKortix } from '@kortix/sdk';
export const kortix = createKortix({
backendUrl: 'https://api.kortix.com/v1',
getToken: async () => getMyToken(),
});Add the providers
Import that module once, then wrap the tree in QueryClientProvider. useSession, the prompt hooks and most data hooks are React Query hooks. They throw without a provider.
'use client';
import '@/lib/kortix';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}Keep one client and one QueryClient per host. A second createKortix() call with other options re-points every earlier client.
useSession
useSession(projectId, sessionId, options?) runs one session view end to end. It polls POST /start until the sandbox is ready, opens the event stream, syncs messages and status, paints saved history, and sends prompts. Call it once per session view.
import { useSession } from '@kortix/sdk/react';
export function SessionView({ projectId, sessionId }: { projectId: string; sessionId: string }) {
const s = useSession(projectId, sessionId);
if (s.messages.length === 0 && s.phase !== 'ready') {
if (s.phase === 'error') return <NotRunning stage={s.stage} onRetry={s.retry} />;
if (s.conversationEmpty) return <Composer onSend={s.send} />;
return s.savedTranscript === 'none' ? <Booting stage={s.stage} /> : <MessageSkeleton />;
}
return (
<>
{s.messages.map(({ info, parts }) => (
<Message key={info.id} info={info} parts={parts} />
))}
{s.sendError && <SendError error={s.sendError} />}
<Composer busy={s.isBusy} disabled={s.phase !== 'ready'} onSend={s.send} onStop={s.cancel} />
</>
);
}
The sandbox is ready when /start answers stage: 'ready'. The hook runs no separate health check. While the session starts, render by this rule:
| State | Render |
|---|---|
messages has rows |
The transcript. Saved history paints before the sandbox wakes. |
No rows and conversationEmpty is true |
The composer. The session has no messages and nothing to wait for. |
No rows and savedTranscript is 'none' |
A boot screen with stage. Nothing can show until the runtime answers. |
No rows and savedTranscript is 'loading' |
Placeholder rows. A saved copy is on its way. |
Saved history is the transcript copy the API writes at each turn end. The first window holds the newest 40 messages, at most 1,000,000 characters. loadOlder() reads older windows, also while the sandbox is off. Live messages then reconcile into it by message id.
To show a sub-agent’s saved history, call useSessionSync(childRuntimeSessionId, { kortixSessionScope: sessionId, savedChild: true }). It reads the child’s saved window from the parent session. The device keeps no copy of it.
Returns
| Field | Type | What it holds |
|---|---|---|
phase |
'starting' | 'ready' | 'error' |
The overall state. Enable sends at ready. error when stage is failed or stopped, or /start failed. |
stage |
string | null |
The raw /start stage: provisioning, starting, ready, stopped or failed. For boot screens. |
startError |
error or null |
The terminal /start failure. Render it instead of a spinner. |
failure |
object or null |
The typed terminal provisioning failure from /start. |
retry |
() => void |
Polls /start again. Use it for a Retry button. |
messages |
{ info, parts }[] |
The transcript. Parts stream in live. |
savedTranscript |
'loading' | 'shown' | 'none' |
Whether saved history can show before the sandbox wakes. An unknown is loading. |
conversationEmpty |
boolean |
The saved copy proves the session has no messages, no turn ended since, and nothing is open or queued. |
freshness |
'idle' | 'loading' | 'fresh' | 'stale' | 'error' |
The transcript read state. loading or error with no rows is a wait or a failure, not an empty session. |
retryTranscript |
() => void |
Reads the transcript tail again. |
hasOlder, isLoadingOlder, loadOlder |
boolean, boolean, () => Promise<void> |
Paging toward older messages. |
status |
SessionStatus |
The runtime status of the session. |
working |
WorkingProjection |
{ state: 'idle' | 'working', pendingDelivery?, source, turnId, since }. The one working answer. |
isBusy |
boolean |
working.state === 'working'. |
isSending |
boolean |
A send request is in flight. |
sendError |
KortixSendError | null |
The last send failure. A new send clears it. |
questions, permissions |
arrays | Open agent questions and tool-approval requests. A permission here is one tool approval, not an access-control permission. |
hasPending |
boolean |
At least one question or permission is open. |
diffs, todos |
arrays | Live file diffs and the todo list. |
rewindMessageId, rewindPending, rewindError |
string | null, boolean, KortixSendError | null |
The staged rewind, a rewind or restore in flight, and its last failure. |
models |
FlatModel[] |
Selectable models. Empty when the project’s llm_gateway flag is off. |
agents, defaultAgent, commands |
arrays, string | null, array |
Selectable agents, the default agent and slash commands. Available before the sandbox starts. |
agentName |
string | null |
The agent the session was created with, from the first /start answer. null when none was bound. |
picks |
SessionPicks |
This session’s chosen model, agent and variant, with setModel, setAgent and setVariant. send uses them. |
runtimeSessionId |
string | null |
The runtime’s root session id, or null while it resolves. opencodeSessionId is a deprecated alias. |
runtimeUrl |
string | null |
The session runtime’s proxy URL, or null until stage is ready. For a terminal attach or a direct runtime call. |
Actions
| Action | What it does |
|---|---|
send(text, override?) |
Sends a prompt through the session’s prompt inbox. The message shows at once, before the server answers. override is { model?, agent?, variant? } for this message only. It does nothing before phase is ready. Failures go to sendError; it never throws. |
sendParts(parts, override?) |
Sends text and file parts. override also takes clientMessageId: send a failed submission again with the same value and the server delivers it once. Throws a not-ready error before ready. |
cancel() |
Stops the running turn. It holds the queued prompts first, so a queued prompt does not start the next turn. Resolves { status: 'aborted' }, { status: 'timed-out' } or { status: 'failed', error }. |
rewind(messageId) |
Rewinds the session to a user message and restores file state. The removed messages stay recoverable until the next prompt. Throws while the session is busy. |
restoreRewind() |
Restores the removed messages before another prompt commits the rewind. |
runCommand(command, args, options?) |
Runs a project slash command. options is { agent?, model?, variant? }. |
answerQuestion(id, answers) |
Answers an open question. answers is string[][], one array per question. |
rejectQuestion(id) |
Rejects an open question. |
answerPermission(id, reply, message?) |
Answers a tool approval. reply is 'once', 'always' or 'reject'. |
The three reply actions remove the item only after the server accepts the reply. On failure they throw a KortixSendError and the item stays open. removeQuestion and removePermission are deprecated: they never reach the agent, so the run stays blocked.
Options
| Option | Default | What it does |
|---|---|---|
enabled |
true |
false holds the whole hook: no /start, no stream. Use it until a precondition passes. |
waitMs |
15000 |
The long-poll budget sent to /start. The server clamps it. |
chatEngine |
true |
false when your app mounts its own chat surface for this session. Then messages, diffs and todos are empty, and questions and permissions stay live. |
subscribeMessages |
true |
false re-renders the caller only when the transcript changes shape, not on each streamed delta. Draw the rows in a child with useSessionMessages. |
replayStartStash |
true |
Sends a first prompt that a “new session” screen saved, once the session is ready and empty. |
initialRuntimeSessionId |
null |
A runtime session id your server already holds. The transcript renders from it while /start runs. The /start answer wins. |
browserPresence |
false |
Holds this tab’s presence lease while a person uses the page (input in the last 10 minutes). The lease keeps the sandbox awake for a person who may start the session, at most 2 hours past its latest turn. |
presenceAlerts |
false |
true tells the server this tab shows its own notifications, so the server skips phone and Web Push while the tab is in use. Pass true only when the project’s notification_center flag is on and this tab’s browser notifications are on and permitted. Needs browserPresence. |
presencePageExit |
false |
Ends the presence lease when the page closes. Off, the lease ends at its 90-second expiry. Needs browserPresence. Pass the project’s notification_center flag. |
Send errors
sendError, rewindError and the reply actions use one type, KortixSendError: { kind, message, cause } plus one detail field per kind. message is ready to display.
kind |
Meaning | Extra field |
|---|---|---|
billing |
The API refused the turn with 402. |
billing: the parsed BillingError. |
connector |
The session needs a connector that has no usable connection. The API refused the turn before the sandbox saw it. | connectors: { id, slug, name, authorization_strategy }[]. project means one shared connection fixes it. user means the person the session runs as must connect. |
runtime-not-ready |
The sandbox is still starting. Try again when phase is ready. |
none |
runtime-error |
Any other failure. | gateway when the LLM gateway answered: { provider?, code?, suggestion?, upstreamStatus?, requestId? }. |
classifySendError(error) turns any thrown value into a KortixSendError.
Pending prompts and turn outcomes
send covers a composer that sends one prompt at a time. Use these hooks for a queue, for steering a running turn, and to say why a turn ended. They take the same projectId and sessionId as useSession.
useSessionPrompts
useSessionPrompts(projectId, sessionId) reads and writes the session’s prompt inbox on the server. The server delivers pending prompts in order, as delivery says.
| Member | What it does |
|---|---|
prompts |
The pending rows. Each has prompt_id, state (queued, delivering, waiting, failed), delivery, placement, full_text and last_error. A delivered prompt leaves the list. |
enqueue(input) |
Adds one prompt. Resolving means the prompt is stored, not delivered. |
edit(promptId, text) |
Replaces a waiting row’s text and keeps its place. |
remove(promptId) |
Removes a waiting row and returns it, so an undo can enqueue it again. |
interrupt(promptId) |
Turns a waiting row into an interrupt: the running turn ends after its running tool, then this row runs. |
retry(promptId) |
Runs this row next. Use it for a failed row. |
hold(held) |
Holds or releases the whole queue. A new send or retry releases it. |
edit, remove and interrupt throw a 409 for a row that is already on its way to the runtime.
enqueue takes delivery (steer, queue or interrupt) and an optional placement. The delivery modes and steer_fallback are in Sessions from code. placement only says where the pending row shows: transcript in the message list (implied by interrupt), composer in the list above the input (implied by steer and queue).
useSessionWorking
useSessionWorking(projectId, sessionId, { runtimeSessionId }) returns the same WorkingProjection as useSession().working. It stays working while a prompt waits in the inbox, and sets pendingDelivery: true while that prompt is still on its way. Keep Stop visible whenever state is working.
Why a turn ended
An interrupted turn’s transcript carries only an abort error. The API records the cause the sandbox named, for example its memory guard. A stop that someone asked for is never a failure.
import { turnEndNotice } from '@kortix/sdk';
import { useSessionTurnOutcome } from '@kortix/sdk/react';
const outcome = useSessionTurnOutcome(projectId, sessionId);
const notice = turnEndNotice(outcome, userMessageId, { hasError, isAbort });
// { kind: 'sandbox-memory', usedPct, detail }
// | { kind: 'cause', name, message }
// | { kind: 'unexplained' }
// | null
Pass the id of the user message that started the turn. hasError is true when the transcript shows an error on that turn, and isAbort is true when that error is an abort. A non-abort error returns null, because the transcript already explains it.
useSessionTurnOutcome makes no request. It reads the cache that useSessionWorking keeps fresh. Map each kind to your own copy. null means show what the transcript shows. turnEndCause(outcome, messageId) returns the raw recorded cause, or null.
Build a chat UI
messages are runtime messages. The chat kit turns each one into typed parts you can render with no knowledge of the harness.
import { toolViewModel } from '@kortix/sdk';
import { renderParts, useChatTurns, type UseSessionResult } from '@kortix/sdk/react';
function Transcript({ messages }: { messages: UseSessionResult['messages'] }) {
const turns = useChatTurns(messages);
return turns.map((turn) =>
turn.isEmpty ? null : (
<Row key={turn.message.info.id} role={turn.message.info.role} error={turn.error}>
{renderParts(turn.parts, {
text: (p) => (p.synthetic ? null : <Markdown key={p.id}>{p.text}</Markdown>),
reasoning: (p) => <Thinking key={p.id} text={p.text} />,
tool: (p) => <ToolCard key={p.id} view={toolViewModel(p.tool)} status={p.tool.status} />,
file: (p) => <Attachment key={p.id} part={p} />,
subtask: (p) => <Subtask key={p.id} part={p} />,
patch: () => null,
snapshot: () => null,
agent: () => null,
retry: (p) => <RetryNotice key={p.id} part={p} />,
compaction: () => <CompactedNotice />,
step: () => null,
unknown: () => null,
})}
</Row>
),
);
}
| Export | From | What it does |
|---|---|---|
useChatTurns(messages) |
@kortix/sdk/react |
One TurnView per message: { message, parts, error?, isEmpty }. Memoized on the messages array. |
renderParts(parts, renderers) |
@kortix/sdk/react |
Calls the renderer for each part’s kind. The type requires one renderer per kind, so a new kind fails your build, not your UI. |
classifyPart(part), classifyTurn(message) |
@kortix/sdk |
The classifiers behind useChatTurns, for code outside React. |
toolViewModel(tool) |
@kortix/sdk |
A typed view of a tool call: web-search, shell, file-read, file-write, file-edit, search, task, todo, question, or generic. |
Part kinds: text, reasoning, tool, file, subtask, patch, snapshot, agent, retry, compaction, step and unknown. A text part with synthetic: true is internal to the runtime; skip it.
A streaming turn changes the transcript about every 16 ms. To keep the rest of the view still, call useSession(projectId, sessionId, { subscribeMessages: false }) in the view, and read the rows in the transcript component with useSessionMessages(session). Pass the whole useSession result. Only that component re-renders per delta. useSessionMessages(session, { throttleMs }) paces the updates.
More on parts, tools and turn grouping: SDK reference.
Read the session log
useSessionLog(session) returns the whole session log (SessionLog): every thread, its messages by seq, and their blocks. useSessionLogThread(session, threadId?) returns one thread: the root thread, or the sub-agent thread threadId names. Pass the whole useSession result. Both start no request: useSession loads the log.
import { useSession, useSessionLogThread } from '@kortix/sdk/react';
function Thread({ projectId, sessionId }: { projectId: string; sessionId: string }) {
const session = useSession(projectId, sessionId, { subscribeMessages: false });
const root = useSessionLogThread(session);
if (!root) return null; // the session reads v1: render useSessionMessages(session)
return <ol>{root.messages.map((m) => <li key={m.message_id}>{m.role}: {m.blocks.length} blocks</li>)}</ol>;
}
A session reads the log only when all of these are true. Otherwise it reads v1, and both hooks return null.
- The runtime’s
/kortix/healthlistssession.log.v2. An absent list does not count. - The project flag
session_logistrue. - The API has a stored log for the session (
GET /v1/projects/:projectId/sessions/:sessionId/loganswers a page). - The session’s thread was converted from v1 (OpenCode). A pi session reads v1.
An SDK older than its API, or an API older than its SDK, reads v1. On v2, useSession paints the device’s saved copy of the log first, then the API’s page, and asks the session stream for schema=v2. What the stream carries depends on the server:
- The runtime side is complete: on
schema=v2the runtime’s stream carrieslog.*frames in place of themessage.*transcript frames, plus every other frame (status, permissions, questions, todos). A message arrives as a streaming record when it starts, then grows with each streamed delta. - The API does not relay
schemayet; that is the remaining step. Until it does, the stream keeps the v1 frames, and a turn in progress streams through them as before.
A session without a stored log yet (a new one) reads v1 and tries the log again at the end of each turn. useSessionMessages, useSession().messages and classifyTurn return the same rows either way. In a thread converted from v1 (OpenCode), a session-log message converts with toV1, the conversion the API uses for v1 readers, and replaces the v1 row with the same id. A pi thread keeps the v1 rows, as the API’s v1 reads do. A deleted message stays out. A row the log does not hold yet, such as the streaming turn or an optimistic send, keeps its place. A row keeps its identity while its message is unchanged.
Hooks
Most hooks below are React Query hooks. Unless the row names another return value, a hook returns the query result (data, isLoading, error, …) plus the mutations the row names. A write refreshes the matching reads.
| Hook | What it gives you |
|---|---|
useAccounts({ userId, enabled }) |
The caller’s accounts. Pass the signed-in person’s userId in any host where the person can change without a reload. |
useProjects(accountId?, options?) |
The projects in one account. |
useProjectSessions(projectId, { scope, parent, startedBy, q, labels, limit }) |
One project’s sessions, paged, newest activity first. Read sessions (flat) and fetchNextPage. parent: 'root' returns top-level sessions with child_count. startedBy is 'me', 'others' or 'automated'. q searches every session the caller can open. scope: 'project' is the manager inventory. |
useSessionChildren(projectId, parentSessionId, { q, limit, enabled }) |
The sessions one parent started, paged. Keep enabled: false until the parent row opens. |
useProjectModels(projectId) |
Selectable models as FlatModel[]. Empty when the project’s llm_gateway flag is off. |
useProjectModelPickerCatalog(projectId) |
The full model-picker record per model (reasoning_options, temperature, limit). undefined unless the project’s llm_gateway flag is on. |
useModelAccess(projectId) |
The project’s disabled providers and models, and setEnabled({ target, id, enabled }) with target 'provider' or 'model'. |
useProjectConfig(projectId) |
The project’s config summary (default agent and slash commands) as ProjectConfigSummary, or undefined while it loads. |
useVisibleAgents({ projectId }) |
The agents a person can pick, as Agent[]. |
useProjectSecrets(projectId) |
Secrets, with upsert, remove, setPersonal and removePersonal. Values are never returned. |
useAccountSecretResources(accountId, projectId?), useSessionProviderSecretPools(projectId, sessionId) |
Account-level secret resources, and the provider-key pools one session can use. |
useProjectTriggers(projectId) |
Triggers, with create, update, remove and fire. |
useProjectTriggerEventTypes(projectId, connector) |
The app events a connector can trigger on. Idle until connector is set. |
useProjectReminders(projectId), useSessionReminders(projectId, sessionId) |
Reminders, with update and remove. The project hook adds updateMany and removeMany. |
useChangeRequests(projectId, status?) |
Change requests, with open, merge, close and requestChanges. |
useProjectApps(projectId) |
Apps, with create, update, start, stop and remove. |
useAppDeployments, useAppDeployment |
Deployments with deploy and rollback, and one deployment with its build log. |
useAppMachines(projectId, appId), useAppVolumes(projectId, appId), useAppVolumeSnapshots(projectId, appId, volumeId), useAppAccess |
Machines with start, stop, restart, destroy and scale (polled every 5 s). Volumes with snapshot, restore, fork, extend and destroy. A volume’s snapshots, newest first. useAppAccess returns { policy, session, update }. See Apps. |
useKortixAppViewer() |
Inside an App: the person who is looking, as { status: 'loading' | 'anonymous' | 'viewer', viewer }. |
useConnectorQuery(projectId, slug, action, args, { account, enabled, staleTime }) |
One connector action’s output as a cached query. Use it only for actions that read: every fetch runs the action. See Connectors. |
useConnectorSetup(token, { backendUrl, storage, openPopup, onOpened }) |
The flow behind a connect link (/connect/<token>): phase (loading, ready, starting, opened, connected, error), info, error, connectedAs and connect(). You supply the popup opener. |
useNotificationInbox({ userId, limit, enabled }) |
The caller’s newest notifications (limit 1 to 50, default 20), unreadCount, markRead(ids), markAllRead() and markSessionRead(sessionId). Polls every 60 s while the page is visible. |
useNotificationPreferences({ userId, enabled }) |
Push and email choices per kind, and update(patch). |
useSessionWatch({ userId, projectId, sessionId, enabled }) |
Whether the caller follows one session, and setWatching(boolean). |
useFeatureFlag(projectId, key) |
{ enabled, isLoading }. enabled is true only when the server says so. Loading and errors read false. |
useRuntimeSupports(capability) |
Whether the open session’s runtime serves a capability such as session.rewind or session.compact. true until the runtime first answers. See Runtime capabilities. |
useCan(target, action) |
{ allowed, reason, isLoading, isError } for one action. target is { accountId }, { accountId, resource } or { projectId }. Loading and errors read allowed: false. |
useCans(target, actions) |
One request for several actions on one target. Returns a map keyed by action. Keep actions stable across renders. |
usePermissionsFor(accountId, memberUserId, probes) |
What another member can do. Needs member.read on the account. |
useGatewayOverview, useGatewaySeries, useGatewayBreakdown, useGatewaySessions, useGatewayErrors |
LLM gateway usage for a project over days (default 30). |
useGatewayLogs, useGatewayLog |
Gateway request logs, paged, and one log entry. |
useGatewayBudgets, useSetGatewayBudget, useDeleteGatewayBudget |
Gateway budgets. |
useGatewayKeys, useCreateGatewayKey, useRevokeGatewayKey |
Gateway keys. |
useGatewayRoutingPolicy(projectId) |
The project’s model routing policy. |
Rules that apply to several hooks:
useCanand its siblings decide what to show and enable. The API checks every write again. Ask for the action the route checks, for exampleproject.gitops.push, never for a role. After a write that changes access, callinvalidatePermissionProbes(queryClient, { accountId, userId }). A verdict is cached for 5 minutes until then. Action names are in Accounts & access.- The three notification hooks key their cache by
userId. Pass the signed-in person’s id. Gate each withenabled: useFeatureFlag(projectId, 'notification_center').enabled. The flag is off by default. See Notifications. - After a runtime restart or a config reload, call
resetRuntimeQueries(queryClient). The next read then comes from the new runtime.
Fast opens and offline
Start the open read early
prefetchSessionOpen(queryClient, projectId, sessionId) starts the read that a session view needs before the view mounts. It sends one GET …/snapshot request: the session row, the current turn, the prompt queue and the first transcript window. useSession then uses that request instead of its own.
import { prefetchSessionOpen } from '@kortix/sdk/react';
import { useQueryClient } from '@tanstack/react-query';
function SessionLink({ projectId, sessionId }: { projectId: string; sessionId: string }) {
const queryClient = useQueryClient();
const warm = () => void prefetchSessionOpen(queryClient, projectId, sessionId);
return <a href={`/sessions/${sessionId}`} onPointerEnter={warm} onFocus={warm} />;
}
Call it on hover, focus or touch of a session link, or when a route names a session. It never calls /start, so it never wakes a sandbox. It never rejects. A second call for the same session within 30 seconds sends nothing.
Render from the device on a reload
Two stores keep what a person already saw, so a reload or a cold start paints it in the first frame. The server’s answer then refreshes it in place. Both stores are per user and bounded. They come from @kortix/sdk.
| Store | Keeps | Default bounds |
|---|---|---|
createPersistedQueryCache({ storage, userId, shouldPersist }) |
React Query results that shouldPersist accepts. isPersistableQueryKey accepts accounts, projects, project detail, session lists and session rows. A restored entry refetches when it is first read. |
1,000,000 characters, 7 days |
createSavedCopyStore({ storage, userId }), registered with setSavedCopyStore |
The saved transcript copies the server sent. useSession paints a kept copy before the server answers. A copy over the per-copy limit keeps its newest messages. |
12 sessions, 1,500,000 characters, 300,000 characters per copy, 14 days |
import type { QueryClient } from '@tanstack/react-query';
import {
browserKeyValueStorage,
createPersistedQueryCache,
createSavedCopyStore,
isPersistableQueryKey,
setSavedCopyStore,
} from '@kortix/sdk';
export function adoptDeviceCaches(queryClient: QueryClient, userId: string) {
const storage = browserKeyValueStorage(); // null where localStorage is blocked or absent
if (!storage) return null;
const cache = createPersistedQueryCache({ storage, userId, shouldPersist: isPersistableQueryKey });
cache.restore(queryClient); // synchronous with localStorage
const stop = cache.persist(queryClient);
window.addEventListener('pagehide', () => void cache.flush());
const copies = createSavedCopyStore({ storage, userId });
setSavedCopyStore(copies);
return { cache, copies, stop };
}
persistwrites at most once per second. Callflush()onpagehide, so a reload keeps the last change.- On React Native, pass AsyncStorage as
storage.restorethen returns a promise. - The saved-copy store keeps only copies the server sent at a turn end, never the live transcript. A stopped turn cannot come back as a running one.
- A full, corrupt or missing store never throws. The app then opens from the server, as without the store.
Sign-out and identity changes
The SDK keeps per-person session state in memory: transcripts, open questions and permissions, turn receipts and model picks. On every identity change, clear it and your caches in one handler. An identity change is a sign-out, a different person signing in, and a sign-in in another tab that changes the person without a page load.
import type { QueryClient } from '@tanstack/react-query';
import { setSavedCopyStore } from '@kortix/sdk';
import { resetIdentityState } from '@kortix/sdk/react';
export async function onIdentityChange(queryClient: QueryClient, device: ReturnType<typeof adoptDeviceCaches>) {
setSavedCopyStore(null);
device?.stop();
queryClient.clear();
resetIdentityState();
await device?.cache.clear();
await device?.copies.clear();
}
resetIdentityState() never throws. It also removes the model picks from localStorage. It does not clear React Query, your own stores or the token source you passed to createKortix. Reset those in the same handler. Neither device store ever restores data for another person, but a signed-out device must not keep it.