Ai Core/Client Persistence logo

Ai Core/Client Persistence

OrganizationPopular
TanStack
ai-core/client-persistence

Browser chat persistence on useChat / ChatClient: localStoragePersistence, sessionStoragePersistence, indexedDBPersistence. Client-authoritative (adapter, full transcript) vs server-authoritative (persistence: true, no client cache). Reload restore, pending interrupts, mid-stream rejoin with delivery durability. Use for SPA reload durability — NOT server history alone. Also covers generation hooks (useGenerateImage etc.), which take only the server-driven mode: persistence: true hydrates the last generation for the (REQUIRED) threadId from the server on mount and repaints status/result/error, nothing is cached in the browser. No extra package: the adapters ship in the framework packages.

Overview

PublisherTanStack
Repositoryai
Skill nameai-core/client-persistence
Stars
3.1K
Forks
330
Bundled files
Instructions only
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by TanStack on GitHub. Read the source before you install it.

Installation

Install the Ai Core/Client Persistence AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/TanStack/ai.git /tmp/ai
mkdir -p .claude/skills
cp -r /tmp/ai/packages/ai/skills/ai-core/client-persistence .claude/skills/tanstack-ai-core-client-persistence
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Ai Core/Client Persistence in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Ai Core/Client Persistence on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Ai Core/Client Persistence is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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

AdapterSurvivesNotes
localStoragePersistence()Reloads + browser restartsSync hydrate; quota-bound; JSON codec default
sessionStoragePersistence()Reloads in the same tabCleared when tab/session ends
indexedDBPersistence()Reloads + restartsAsync 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
import {
  useChat,
  fetchServerSentEvents,
  localStoragePersistence,
} from '@tanstack/ai-react'

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
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'

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
import { useGenerateImage, fetchServerSentEvents } from '@tanstack/ai-react'

function ImageGenerator({ threadId }: { threadId: string }) {
  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
      // is filed under it, and the client hydrates by it on mount.
      threadId,
      stream: true,
      middleware: [withGenerationPersistence(persistence)],
    }),
  )
}

// Mount-time hydration: resolves `?runId=` (preferred) or the latest run linked
// to `?threadId=`, and returns `{ resumeSnapshot, activeRun }`.
export function GET(request: Request) {
  return reconstructGeneration(persistence, request, {
    // Multi-user routes MUST authorize: the ids come from the caller. Derive
    // identity from server-side session state, then check ownership.
    authorize: async (id, req) => {
      // const user = await auth(req)
      // return user != null && (await db.threadOwnedBy(user.id, id))
      void id
      void req
      return true
    },
  })
}
  • Nothing is cached client-side. On mount the client hydrates the last generation for its threadId from the server via the connection's hydrateGeneration handler (the SSE/HTTP adapters issue a GET with ?threadId= to the same endpoint URL) and repaints it into the normal fields.
  • The server GET returns reconstructGeneration(persistence, request) from @tanstack/ai-persistence — it resolves the run by ?runId= (preferred) or the latest run linked to ?threadId=, and needs stores.generationRuns. Pair it with withGenerationPersistence on the generation route. See ai-core/media-generation and ai-persistence.
  • Best for multi-device / compliance (no generation metadata in browser storage), exactly like chat's server-authoritative mode.

Restoring media: byte storage + artifactUrl

result comes back with its media only when the server persists the bytes (stores.artifacts + stores.blobs) AND withGenerationPersistence is given an artifactUrl mapper:

ts
withGenerationPersistence(persistence, {
  artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`,
})

artifactUrl stamps a durable app-origin URL onto each persisted ref and rewrites the live result's media to it, so live and restored results match. The durable refs travel on result.artifacts; on restore the hook rebuilds result from them, so result.images[i].url (or a video's result.url) serves from your own origin. result.artifacts is the whole artifact surface on the hook. Without byte storage, a reload restores status / error and result stays null.

Also worth knowing:

  • stop() marks the record no longer resumable; reset() clears the in-memory snapshot.
  • Nothing auto-runs from a hydrated record — generate(...) is always explicit.
  • Use status / result for a finished run; use runId to tell that a run was still generating when the page closed, and to name it to your own server (to cancel or poll the provider job — stop() only aborts the local stream).

Common mistakes

HIGH: No threadId

Record cannot be found after reload.

HIGH: Passing id to useChat

Removed — threadId is the identity. (ChatClient still accepts id directly as a lower-level escape hatch for keying storage separately from the wire thread; the framework hooks do not.)

HIGH: persistence: true without server history

Empty chat after reload unless the server can reconstruct by threadId.

MEDIUM: Huge transcripts in localStorage

Quota and main-thread cost. Prefer persistence: true + server store, or IndexedDB with care.

MEDIUM: Expecting multi-device sync from client storage alone

localStorage is per-browser. Use server persistence for multi-device.

Cross-references

  • ai-persistence/server (@tanstack/ai-persistence) — authoritative server half
  • ai-core/chat-experienceuseChat, resumable connections
  • Resumable streams docs — mid-stream rejoin

Frequently asked questions

What does the Ai Core/Client Persistence AI skill do?

Browser chat persistence on useChat / ChatClient: localStoragePersistence, sessionStoragePersistence, indexedDBPersistence. Client-authoritative (adapter, full transcript) vs server-authoritative (persistence: true, no client cache). Reload restore, pending interrupts, mid-stream rejoin with delivery durability. Use for SPA reload durability — NOT server history alone. Also covers generation hooks (useGenerateImage etc.), which take only the server-driven mode: persistence: true hydrates the last generation for the (REQUIRED) threadId from the server on mount and repaints status/result/erro...

Why use Ai Core/Client Persistence on TypingMind?

Because you install it once and use it with any model. Ai Core/Client Persistence is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Ai Core/Client Persistence in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/TanStack/ai/tree/main/packages/ai/skills/ai-core/client-persistence. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Ai Core/Client Persistence?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Ai Core/Client Persistence?

As many as you like. As long as a model supports skills, you can use Ai Core/Client Persistence with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Ai Core/Client Persistence AI skill free?

Yes. It is published on GitHub by TanStack under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇