---
title: React
description: "@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

1. **Install**

    Install the SDK and its two optional peers: `react` 18 or later and `@tanstack/react-query` 5.75.2 or later.

    ```bash
    npm install @kortix/sdk react @tanstack/react-query
    ```

2. **Create the client**

    Create the client once, at module scope. Every hook reads this client. See [SDK](/docs/sdk) for the options and [Authentication](/docs/sdk/auth) for `getToken`.

    ```ts
    // lib/kortix.ts
    import { createKortix } from '@kortix/sdk';

    export const kortix = createKortix({
      backendUrl: 'https://api.kortix.com/v1',
      getToken: async () => getMyToken(),
    });
    ```

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

    ```tsx
    '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.

```tsx
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](/docs/sdk/sessions#send-prompts). `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.

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

```tsx
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](/docs/sdk/reference#custom-transcript-rendering).

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

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

1. The runtime's `/kortix/health` lists `session.log.v2`. An absent list does not count.
2. The project flag `session_log` is `true`.
3. The API has a stored log for the session (`GET /v1/projects/:projectId/sessions/:sessionId/log` answers a page).
4. 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=v2` the runtime's stream carries `log.*` frames in place of the `message.*` 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 `schema` yet; 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](/docs/apps#build-on-apps-from-typescript). |
| `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](/docs/connect/connectors#call-connectors-from-code). |
| `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](/docs/sdk/sessions#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:

- `useCan` and its siblings decide what to show and enable. The API checks every write again. Ask for the action the route checks, for example `project.gitops.push`, never for a role. After a write that changes access, call `invalidatePermissionProbes(queryClient, { accountId, userId })`. A verdict is cached for 5 minutes until then. Action names are in [Accounts & access](/docs/accounts#permissions).
- The three notification hooks key their cache by `userId`. Pass the signed-in person's id. Gate each with `enabled: useFeatureFlag(projectId, 'notification_center').enabled`. The flag is off by default. See [Notifications](/docs/work/sessions#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.

```tsx
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 |

```ts
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 };
}
```

- `persist` writes at most once per second. Call `flush()` on `pagehide`, so a reload keeps the last change.
- On React Native, pass AsyncStorage as `storage`. `restore` then 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.

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