Skip to content

AI SDK recipes

Follow and resume the same chat generation across tabs.

Open recipe

Resumable chat · Vite · Svelte

Start with an existing Vite · Svelte app. Keep its renderer, routes and plugins. These files add one live view; Spinetab does not create your API.

Your backend returns AI SDK UI-message streams: POST /api/chat starts a generation and GET /api/chat/shared-chat/stream replays the active generation from its first start chunk. It echoes the supplied generation ID in x-generation-id or the start chunk's messageId. Return HTTP 204 when no generation is active.

Serve Cache-Control: no-store, no-transform. Your proxy must forward chunks promptly and close downstream responses on upstream failure. See the backend contract before using an ordinary chat route.

The examples use same-origin URLs and declare anonymous: true: no token is supplied, although cookies still flow. For a separate API origin, use its URL and configure CORS or your application's proxy. For private data, add credentials and user scopes; never put a secret in these files.

Terminal window
pnpm add spinetab ai@^7.0.116 zod@^4 @ai-sdk/svelte@^5.0.116

For strict AI SDK declarations, install these types and include node if your tsconfig restricts types:

Terminal window
pnpm add -D @types/json-schema @types/node

Keep your framework's existing dependencies. See compatible versions if upgrading an older app.

Merge this addition into your configuration; preserve existing plugins and options. Restart the dev server afterwards. The plugin bundles the worker and discovers the adapters imported below.

vite.config.ts
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { spinetab } from "spinetab/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [svelte(), spinetab()] });

Save these files together in src/recipe/.

src/recipe/live.ts
import { createSpinetab } from "spinetab";
export const spinetab = createSpinetab({ anonymous: true });
src/recipe/transport.ts
import { SpinetabChatTransport } from "spinetab/ai-sdk";
import { spinetab } from "./live";
export const transport = new SpinetabChatTransport({
client: spinetab,
api: "/api/chat",
});
// Both tabs must use the same authorised chat ID. Remount the view when it changes.
export const chatId = "shared-chat";
src/recipe/Recipe.svelte
<script lang="ts">
import { Chat } from "@ai-sdk/svelte";
import { onMount } from "svelte";
import { chatId, transport } from "./transport";
const chat = new Chat({ id: chatId, transport });
let mounted = $state(false);
const busy = $derived(
chat.status === "submitted" || chat.status === "streaming",
);
onMount(() => {
mounted = true;
const resume = () => chat.resumeStream();
const unfollow = transport.follow(chatId, resume);
void resume();
return () => {
unfollow();
void chat.stop();
};
});
</script>
<section aria-label="Chat">
{#each chat.messages as message (message.id)}
<p>
{message.parts.map((part) => part.type === "text" ? part.text : "").join("")}
</p>
{/each}
{#if chat.error}
<p role="alert">{chat.error.message}</p>
{/if}
<p role="status">{chat.status}</p>
<button
type="button"
disabled={!mounted || busy}
onclick={() => { void chat.sendMessage({ text: "Explain shared browser connections in one sentence." }); }}
>
Send
</button>
</section>
src/App.svelte
<script lang="ts">
import Recipe from "./recipe/Recipe.svelte";
</script>
<Recipe />

Your existing entry point mounts App as usual. Keep its renderer plugin; no additional application provider is required beyond those shown.

Open the same chat in two tabs and press Send in one. Both should display the same generation. A late tab explicitly resumes from the backend; it may need another HTTP response, but must not start another generation. Unmounting detaches this view and its follow observer, not the server's generation. Remount the component when the chat ID changes. Durable history and an explicit server-side stop endpoint belong to your app.

API options and advanced recovery · Authentication