Install in Claude Code
Copygit clone --depth 1 https://github.com/TanStack/ai /tmp/ai-core-client-persistence && cp -r /tmp/ai-core-client-persistence/packages/ai/skills/ai-core/client-persistence ~/.claude/skills/ai-core-client-persistenceThen start a new Claude Code session; the skill loads automatically.
Definition
SKILL.md
# Client Persistence
> Builds on ai-core, and on `ai-core/chat-experience` for `useChat` itself.
>
> **No extra package.** The adapters below ship in the **framework** packages
> (`@tanstack/ai-react` and friends, re-exported from `@tanstack/ai-client`),
> so browser persistence needs nothing installed beyond what a chat UI already
> has. The **server** half is a separate package — see
> `@tanstack/ai-persistence` and its `ai-persistence/server` skill.
A `ChatClient` / `useChat` keeps messages in memory. The `persistence` option
stores one record per `threadId` so a reload can repaint the transcript,
restore a pending interrupt, and rejoin an in-flight run.
Import adapters from the **framework package** (not `@tanstack/ai-client`
unless vanilla JS):
```tsx
import {
useChat,
fetchServerSentEvents,
localStoragePersistence,
sessionStoragePersistence,
indexedDBPersistence,
} from '@tanstack/ai-react'
```
## Adapters
| Adapter | Survives | Notes |
| ----------------------------- | -------------------------- | --------------------------------------------------------------- |
| `localStoragePersistence()` | Reloads + browser restarts | Sync hydrate; quota-bound; JSON codec default |
| `sessionStoragePersistence()` | Reloads in the same tab | Cleared when tab/session ends |
| `indexedDBPersistence()` | Reloads + restarts | Async open (first paint may be empty briefly); structured clone |
All default to the chat persisted-state shape — no type argument or codec
required for normal use.
## Mode A — cache everything (client-authoritative)
```tsx
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat', // stable — required
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
})
// ...
}
```
Bare adapter ≡ full transcript + resume pointer. Browser owns history; server
(if any) mirrors when you post non-empty `messages`.
Best for: SPA, offline-first, single device, moderate conversation size.
## Mode B — server-authoritative (`persistence: true`)
```tsx
function Chat({ threadId }: { threadId: string }) {
const { messages, sendMessage } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
// ...
}
```
Nothing is cached client-side: no transcript, no resume pointer.
On mount, `useChat` hydrates the thread from the **server** by `threadId`
(paint + tail active run). Same path for another device. Pair with server
`withPersistence` + a hydrate route (`reconstructChat` or equivalent).
Best for: large transcripts, multi-device, compliance (no message bodies in
browser storage).
## What a reload restores
1. **Finished run** — transcript from the adapter (mode A) or server (mode B).
2. **Paused on interrupt** — approval UI restored (from the adapter in mode A,
the server hydrate in mode B).
3. **Still streaming** — needs **delivery durability** on the route
(`toServerSentEventsResponse(stream, { durability: … })`) so the client can
`joinRun` and finish the reply. Persistence alone is not enough.
## Stable `threadId` is the identity
Persistence keys on `threadId`. The hooks have **no separate `id` option** — a
chat's identity _is_ its `threadId`. Without a stable one, each load is a new
chat. Generate it server-side or from a route param the user owns; do not
randomize per mount.
## Generation hooks: server-driven only
The generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGeneration`,
`useSummarize`, `useTranscription`, …) take a `persistence` option too, but it is
**boolean only** — there is no storage-adapter mode, and the browser caches
nothing. **The hooks are transparent, mirroring `useChat`:** a reload repaints the
hook's
**normal** fields — `status` (`'idle'` / `'generating'` / `'success'` /
`'error'`), `error`, and `result` — as if the run had just finished. There is
**no** `resumeSnapshot`, `resumeState`, `pendingArtifacts`, or `resultArtifacts`
field. The one extra field is `runId`: the id of the generation job currently
running, or `null` when nothing is in flight. The persisted record holds run
identity, status, error, and result metadata (ids, model, a provider video job
id), **never the generated media bytes**.
The hook return is exactly `generate` / `result` / `isLoading` / `error` /
`status` / `stop` / `reset` / `runId`.
### Turning it on (`persistence: true`)
```tsx
const image = useGenerateImage({
threadId, // REQUIRED — the scope the last generation is hydrated under
connection: fetchServerSentEvents('/api/generate/image'),
persistence: true,
})
// After a reload: image.status / image.result / image.error are the last
// generation for `threadId`, fetched from the server — nothing was cached.
```
The server half — the same route handles the run and the hydration `GET`:
```ts
import {
generateImage,
generationParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
memoryPersistence,
reconstructGeneration,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
// Needs `stores.generationRuns`; `memoryPersistence()` ships one.
const persistence = memoryPersistence()
export async function POST(request: Request) {
const { input, threadId } = await generationParamsFromRequest(
'image',
request,
)
if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}
if (threadId === undefined) {
throw new Error('Generation persistence requires a `threadId`.')
}
return toServerSentEventsResponse(
generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
// The stable slot this run fills. Required by persistence: the run record
// iMore from this repository
gap-analysisSkill
>
triage-githubSkill
Triage all open GitHub issues, PRs, and discussions in the current repository by fanning out up to 100 parallel subagents (one per item), then produce a single prioritized report ranking which PRs to review first, which issues to address first, and which discussions need maintainer attention. Use when the user asks to "triage open issues/PRs", "triage discussions", "prioritize the backlog", "what should I review first", "sweep the repo", or any request to bulk-evaluate open GitHub work and recommend an order.
ai-code-modeSkill
>
ai-mcpSkill
>
ai-coreSkill
>
ai-core/adapter-configurationSkill
>
ai-core/ag-ui-protocolSkill
>
ai-core/chat-experienceSkill
>