From e4f97e224c11acb9720c8db765a70c149a31c5c0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 15 Oct 2026 15:42:49 +0000
Subject: [PATCH 1/6] build(robots): let AI fetchers read the release-candidate
docs
On a host other than docs.solidjs.com, robots.txt used to be
`User-agent: * / Disallow: /`, which blocked every crawler and AI fetcher
from the live Solid 2.0 docs, including /llms.txt, /llms-full.txt and the
per-page `.md` form.
The non-production rule now allows everything, and the site is kept out
of search results with an `X-Robots-Tag: noindex` response header set
through a Nitro route rule. The Netlify preset writes route-rule headers
into `_headers`, so prerendered pages served from the CDN carry it too.
The canonical-host rule is unchanged.
Deployed robots.txt on a non-canonical host (for example v2.solidjs.com):
User-agent: *
Allow: /
Sitemap: https://v2.solidjs.com/sitemap.xml
Deployed `_headers` on that host:
/*
X-Robots-Tag: noindex
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY
---
vite.config.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/vite.config.ts b/vite.config.ts
index a3254e993..40eeffe12 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -149,6 +149,11 @@ export default defineConfig({
},
llms: true,
sitemap: true,
+ // Every host serves llms.txt, llms-full.txt, and each page's `.md`
+ // form to any client. The canonical host is indexed; a preview or
+ // release-candidate host is kept out of search results by the
+ // `X-Robots-Tag: noindex` header below, not by a crawl block, so
+ // that AI fetchers and link previews still reach the content.
robots: {
rules: isProductionSite
? [
@@ -158,7 +163,7 @@ export default defineConfig({
disallow: ["/i18n-status/"],
},
]
- : [{ userAgent: "*", disallow: ["/"] }],
+ : [{ userAgent: "*", allow: ["/"] }],
},
}),
solidStart(
@@ -166,6 +171,11 @@ export default defineConfig({
),
nitro({
preset: "netlify",
+ // The Netlify preset writes route-rule headers to `_headers`, so the
+ // header also covers prerendered pages served from the CDN.
+ routeRules: isProductionSite
+ ? {}
+ : { "/**": { headers: { "X-Robots-Tag": "noindex" } } },
prerender: {
crawlLinks: true,
autoSubfolderIndex: false,
From 44272123a9c8d04b922ea6575de5108438b4deaf Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 15 Oct 2026 15:48:33 +0000
Subject: [PATCH 2/6] docs(guides): add "Solid for AI tools"
A guide that lists every AI-facing asset Solid 2.0 ships and tells the
reader what to put in an assistant's instructions file: the installed
cheatsheet, the four skills (the solid-2.0 primer, reactivity-diagnostics,
agent-loops, server-components), the coded console diagnostics with their
repair-guide footer, DEV and OBSERVE, the @solidjs/diagnostics assertions,
the /__solid/diagnostics dev endpoint and its methods, the
eslint-plugin-solid v2 and v2-strict configs, llms.txt, llms-full.txt and
the per-page .md form, and the templates' AGENTS.md.
Ends with a paste-ready rules block for AGENTS.md / CLAUDE.md /
.cursor/rules: the three rules that matter most, the 1.x names that no
longer exist with their replacements (checked against CHEATSHEET.md and
MIGRATION.md), and the pointers above. Linked from the Overview page.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY
---
src/routes/(0)index.mdx | 2 +
src/routes/(5)guides/(17)ai-tools.mdx | 215 ++++++++++++++++++++++++++
2 files changed, 217 insertions(+)
create mode 100644 src/routes/(5)guides/(17)ai-tools.mdx
diff --git a/src/routes/(0)index.mdx b/src/routes/(0)index.mdx
index 8b70e8489..8b3bc515a 100644
--- a/src/routes/(0)index.mdx
+++ b/src/routes/(0)index.mdx
@@ -33,6 +33,8 @@ Developers moving from Solid 1 or SolidStart have their own guides under [Migrat
If you know what you need and want the signature, go to the [Reference](/reference).
It is organized by import specifier, so `solid-js`, `@solidjs/web`, and `@solidjs/router` each have their own section.
+If you write Solid with a coding assistant, [Solid for AI tools](/guides/ai-tools) lists the cheatsheet, skills, and diagnostics that ship with the packages and gives you a rules block to paste into its instructions file.
+
## A reading order
The Learn pages build on each other.
diff --git a/src/routes/(5)guides/(17)ai-tools.mdx b/src/routes/(5)guides/(17)ai-tools.mdx
new file mode 100644
index 000000000..e61f60953
--- /dev/null
+++ b/src/routes/(5)guides/(17)ai-tools.mdx
@@ -0,0 +1,215 @@
+---
+title: "Solid for AI tools"
+version: "2.0"
+description: "Point a coding assistant at the cheatsheet, skills, diagnostics, lint configs, and Markdown docs that ship with Solid 2.0, and paste a rules block into its instructions file."
+---
+
+An assistant asked to add a feature to a Solid 2.0 app writes code from what it learned elsewhere: props destructured in the signature, an effect with one function, a store written through a path setter, `Suspense` and `createResource`.
+Each of those is wrong in 2.0, and some of them run without an error, so the mistake shows up as a value that never updates rather than as a stack trace.
+
+Solid 2.0 ships the material that corrects this, and most of it is installed with the packages so it matches the exact version in `node_modules`.
+This page lists every piece, says what it is for, and ends with a block to paste into the assistant's instructions file.
+
+## What ships with the packages
+
+### The cheatsheet
+
+`node_modules/solid-js/CHEATSHEET.md` is a one-page reference for Solid 2.0: imports, every primitive with a short example, and a section at the end titled "What changed from 1.x" that lists the renamed and removed names with their replacements.
+Point an assistant at it before its first edit; the section at the end is written for a model whose training data is 1.x.
+
+### Skills
+
+A skill is a Markdown file an assistant reads on demand, placed under `skills//SKILL.md` inside the package that owns the behavior it describes.
+Four ship with Solid 2.0:
+
+| Path | Read it when |
+| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `node_modules/solid-js/skills/solid-2.0/SKILL.md` | Before the first edit. The primer: the run-once component model, where reads and writes may happen, and the shapes that replace 1.x APIs. |
+| `node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md` | A diagnostic code such as `[REACTIVE_WRITE_IN_OWNED_SCOPE]` appears in the console or a test. One section per code: what the runtime observed, what to change. |
+| `node_modules/@solidjs/diagnostics/skills/agent-loops/SKILL.md` | Verifying a reactive change: how to capture which scopes re-ran and why, and how to turn that into a budget a test asserts. |
+| `node_modules/@solidjs/web/skills/server-components/SKILL.md` | Writing a `"use server"` component: where server markup ends, where a client slot begins, and the rules for binding the two. |
+
+The versions in `node_modules` match the installed runtime, which is why the paths above point there rather than at GitHub.
+The same files are in the [solid repository](https://github.com/solidjs/solid/tree/next/packages) for reading in a browser.
+
+### The template's AGENTS.md
+
+Every project created with `pnpm create solid@latest` from a `solid-v2` template contains an `AGENTS.md` at its root.
+It names the installed skills, explains the diagnostics endpoint, and tells the assistant to name its signals, memos, and effects so attribution reports read well.
+Keep it; an assistant that supports the file reads it on every task.
+
+## Diagnostics an assistant can read
+
+A development build of Solid reports structural mistakes as coded console warnings, and the same findings are available as data in tests and over HTTP.
+An assistant that works from evidence rather than from reading the code makes fewer wrong fixes, so each channel is listed here with how to reach it.
+
+### Coded warnings with a repair-guide footer
+
+In development, the runtime reports a structural mistake as a console diagnostic: a reactive value read outside a tracking scope, a write from inside a memo, an effect that re-runs because of its own write.
+Each report carries a stable code in brackets and an `in` line naming the owners that enclose the subject.
+The first report of each code ends with a footer that names the installed repair guide and the same file on GitHub, anchored to that code:
+
+```
+[REACTIVE_WRITE_IN_OWNED_SCOPE] repair guide: node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md — https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#reactive_write_in_owned_scope
+```
+
+Tell the assistant to treat every code as a defect to look up in the guide, not as noise to suppress.
+[Debugging reactivity](/guides/debugging-reactivity) explains how to read a report.
+
+### `DEV` and `OBSERVE`
+
+[`DEV`](/reference/solid-js/advanced/diagnostics-dev-hooks/dev) is the development-only hook object exported from `solid-js`; it is `undefined` in a production build.
+[`OBSERVE`](/reference/solid-js/advanced/diagnostics-dev-hooks/observe) is its counterpart in the observe build, which also carries the records and attribution channels that tooling subscribes to.
+An assistant does not call these directly in app code; they are what the diagnostics package and the Vite plugin are built on, and they are listed here so an assistant that finds them in a stack trace knows what they are.
+
+### `@solidjs/diagnostics` in tests
+
+`captureArtifact` from `@solidjs/diagnostics` runs a scenario and returns a serializable artifact of every diagnostic and every re-run with its cause.
+`expectNoDiagnostics(artifact)` throws with the offending events as evidence, and `expectRerunBudget` bounds how often a named scope may re-run.
+The same checks are Vitest matchers, `toHaveNoDiagnostics` and `toStayWithinRerunBudget`, when the setup file imports `@solidjs/diagnostics/vitest`.
+
+```ts
+import {
+ captureArtifact,
+ expectNoDiagnostics,
+ expectRerunBudget,
+} from "@solidjs/diagnostics";
+
+const { artifact } = await captureArtifact(
+ () => {
+ // mount a tree, perform the interaction under test
+ },
+ { scenario: "cart-add" }
+);
+
+expectNoDiagnostics(artifact);
+expectRerunBudget(artifact, 1, { scope: "total" });
+```
+
+An assistant can run this loop on its own: generate, capture, read the events, fix, capture again, until the artifact is empty.
+[Check for regressions in tests](/guides/debugging-reactivity#check-for-regressions-in-tests) shows the matcher form.
+
+### The dev server endpoint
+
+With `diagnostics: true` in the `@solidjs/vite-plugin` options, the dev server serves `/__solid/diagnostics`.
+`GET` returns the status, including whether a page is connected.
+`POST` with a JSON body `{"method": ...}` drives a capture session against the open page:
+
+| Method | Params | Result |
+| ----------- | -------------------------- | ------------------------------------------- |
+| `begin` | optional `{ attribution }` | opens a capture session |
+| `end` | none | closes it and returns the captured payload |
+| `active` | none | whether a session is open |
+| `whyDidRun` | `{ "name": "..." }` | the recorded re-runs of one named scope |
+| `costs` | none | the running cost tables of the open session |
+
+```sh
+curl -X POST localhost:3000/__solid/diagnostics -d '{"method":"begin"}'
+# interact with the page in a browser
+curl -X POST localhost:3000/__solid/diagnostics -d '{"method":"whyDidRun","params":{"name":"CartTotal"}}'
+curl -X POST localhost:3000/__solid/diagnostics -d '{"method":"end"}'
+```
+
+Scopes are reported by name, so an assistant that names its memos and effects with the `{ name }` option gets answers it can grep.
+The endpoint is development-only and does nothing in a build.
+
+## Lint
+
+`eslint-plugin-solid` ships two configs for Solid 2.0.
+`eslint-plugin-solid/configs/v2`, which the templates enable, switches the version-aware rules to 2.0 semantics and turns on the 2.0-specific rules as errors: `removed-api`, `no-single-arg-create-effect`, `no-accessor-as-prop`, `no-store-mutation-outside-setter`, `no-write-in-pure-computation`, and the server function rules.
+`eslint-plugin-solid/configs/v2-strict` adds `no-module-scope-reactive-primitive` and `no-restated-default-options` as errors and promotes the warnings of `v2` to errors.
+Each of those rules names a mistake an assistant makes often, so running the linter after an edit reports it before the code runs.
+The templates wire the config through oxlint:
+
+```ts
+// oxlint.config.ts
+import { defineConfig } from "oxlint";
+import solidV2 from "eslint-plugin-solid/configs/v2";
+
+export default defineConfig({
+ jsPlugins: ["eslint-plugin-solid"],
+ settings: solidV2.settings,
+ rules: solidV2.rules,
+});
+```
+
+## These docs as Markdown
+
+This site serves its content in the forms an assistant can fetch:
+
+- [`/llms.txt`](/llms.txt) is an index of every page with its one-line description.
+- [`/llms-full.txt`](/llms-full.txt) is the whole site in one Markdown file.
+- Every page is available as Markdown at its own URL with `.md` appended, for example `/concepts/reactivity.md`.
+
+Give an assistant the index and let it fetch the pages it needs; the full corpus is for tools that load everything once.
+
+## Rules to paste into an instructions file
+
+Drop this block into `AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, or whichever file the assistant reads, and keep it next to the project's own conventions.
+It holds the three rules that account for most generated bugs, the names that no longer exist, and pointers to the files above.
+
+```md
+## Solid 2.0
+
+This project uses Solid 2.0 (`solid-js` 2.x with `@solidjs/web`). Components run once;
+there is no re-render. Read `node_modules/solid-js/CHEATSHEET.md` before writing Solid code,
+especially its last section, "What changed from 1.x".
+
+Rules that matter most:
+
+- Props are read, never destructured: `props.name` inside JSX, a memo, or an effect.
+ `function Comp({ name })` loses reactivity. Pass values, not accessors: ``.
+- `createEffect(compute, apply)` takes two functions. The first reads reactive values and
+ returns a result; the second receives it and performs side effects. One argument is an error.
+- Stores are written through the draft: `setStore(s => { s.user.name = "Ada" })`.
+ There are no path setters and no `produce`.
+- Read reactive values inside JSX, a memo, or an effect, never at the top of a component body.
+- Do not write a signal or store from a memo, an effect's compute function, or a component body.
+ Write from event handlers, `onSettled`, or actions.
+
+Names that no longer exist, and what to use:
+
+- `solid-js/web` -> `@solidjs/web`; `solid-js/store` -> `solid-js`
+- `Suspense` -> `Loading`; `SuspenseList` -> `Reveal`; `ErrorBoundary` -> `Errored`
+- `mergeProps` -> `merge`; `splitProps` -> `omit`; `unwrap` -> `snapshot`; `onMount` -> `onSettled`
+- `createResource` -> an async `createMemo` or `createStore(fn)` read under ``
+- `createSelector` -> `createProjection`; `createComputed` -> `createMemo` or a two-function `createEffect`
+- `batch` -> writes batch on a microtask (`flush()` applies them now); `on()` -> the compute half of `createEffect`
+- `startTransition` / `useTransition` -> built in; `isPending()` reports an in-flight change
+- `Index` -> ``; `indexArray` -> `mapArray`; `createMutable` -> `createStore` with draft setters
+- `onError` / `catchError` -> `` or the `error` option of `createEffect`
+- `classList` -> `class={{ active: isActive() }}`; `use:` directives -> `ref={factory(args)}`
+- `Context.Provider` -> ``; `equalFn` -> `isEqual`; `getListener` -> `getObserver`
+
+Tooling:
+
+- Skills: `node_modules/solid-js/skills/solid-2.0/SKILL.md` (primer),
+ `node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md` (every warning code and its fix),
+ `node_modules/@solidjs/diagnostics/skills/agent-loops/SKILL.md` (capture evidence, assert budgets),
+ `node_modules/@solidjs/web/skills/server-components/SKILL.md` (server components and slots).
+- A console warning with a bracketed code, such as `[REACTIVE_WRITE_IN_OWNED_SCOPE]`, is a defect.
+ Look it up in the repair guide; do not suppress it.
+- Verify a reactive change with `captureArtifact` and `expectNoDiagnostics` from `@solidjs/diagnostics`,
+ or `POST /__solid/diagnostics` on the dev server with `{"method":"begin"}` ... `{"method":"end"}`.
+- Lint with `eslint-plugin-solid/configs/v2` (or `v2-strict`).
+- Docs: https://v2.solidjs.com/llms.txt; append `.md` to any page URL for its Markdown.
+```
+
+Every name in the block is checked against the installed cheatsheet; when the cheatsheet and this page disagree, the cheatsheet matches your version.
+
+## Recap
+
+- The cheatsheet, four skills, and the template's `AGENTS.md` are installed with the packages, so they match the version in `node_modules`.
+- A development build reports structural mistakes with a stable code and a footer that names the repair guide; every code is a defect.
+- `captureArtifact` with `expectNoDiagnostics` and `expectRerunBudget` turns a reactive claim into evidence a test or an assistant can check.
+- With `diagnostics: true`, `POST /__solid/diagnostics` drives `begin`, `end`, `active`, `whyDidRun`, and `costs` against the running page.
+- `eslint-plugin-solid/configs/v2` catches destructured props and the other common generated mistakes before the code runs.
+- `/llms.txt`, `/llms-full.txt`, and every page's `.md` URL serve these docs as Markdown.
+- Paste the rules block into the assistant's instructions file and keep the project's own conventions beside it.
+
+## Next steps
+
+- [Debugging reactivity](/guides/debugging-reactivity): how to read a diagnostic report and the test matchers that fail on one.
+- [Thinking in Solid](/guides/thinking-in-solid): the mental model the rules above come from, built up over one feature.
+- [Streaming AI responses](/building-apps/server-functions/streaming-ai-responses): forward a model's stream through a server function into a store, with cancellation.
+- [Chat state for the AI SDK](/guides/ai-sdk-chat): a store-backed adapter for the AI SDK's chat class.
From f2dc195415654b2bd4b1a45eafc4ef72fcccdae7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 15 Oct 2026 15:55:17 +0000
Subject: [PATCH 3/6] docs(server-functions): add "Streaming AI responses"
A guide that forwards a model's token stream through a "use server"
async generator into a store in the browser:
- the server function reads getRequestEvent()?.request.signal and hands
it to the model call, so a dropped connection stops generation;
- createProjection(async function* (draft) { ... }) commits one store
write per chunk, with seedLoadingValue so the seed is the first render;
- a new question supersedes the read, which closes the previous generator
through return() and aborts its request; an explicit Stop uses
invoke(fn, { signal }, ...args) and catches its own abort;
- an Errored boundary with reset for retry;
- Markdown rendered as completed blocks in a For keyed by id plus one
re-rendered trailing block;
- the cancellation contract, including when not to forward the signal
(a durable job exposes Stop as an action and progress as a live source).
Every API is checked against the solid checkout; the ts/tsx blocks
type-check against built types with scripts/check-examples.mjs.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY
---
.../(6)streaming-ai-responses.mdx | 270 ++++++++++++++++++
1 file changed, 270 insertions(+)
create mode 100644 src/routes/(3)building-apps/(3)server-functions/(6)streaming-ai-responses.mdx
diff --git a/src/routes/(3)building-apps/(3)server-functions/(6)streaming-ai-responses.mdx b/src/routes/(3)building-apps/(3)server-functions/(6)streaming-ai-responses.mdx
new file mode 100644
index 000000000..be97180e3
--- /dev/null
+++ b/src/routes/(3)building-apps/(3)server-functions/(6)streaming-ai-responses.mdx
@@ -0,0 +1,270 @@
+---
+title: "Streaming AI responses"
+version: "2.0"
+description: "Forward a model's token stream through a server function into a store one chunk at a time, stop it from the browser or by asking a new question, and render the Markdown without re-rendering what has already landed."
+---
+
+The storefront gets a support assistant: a shopper types a question and the answer appears word by word as the model produces it.
+Halfway through, the shopper asks a different question, or presses Stop, or the model call fails and they want to try again.
+Written by hand, that is a `fetch` with a body reader, a flag for "streaming", an `AbortController` kept somewhere, and a text state that is replaced on every chunk.
+
+In Solid the model call is a server function that returns an async generator, the browser side is a projection that writes one store commit per chunk, and cancellation is what the reactive graph does with a computation it no longer needs.
+This guide builds that assistant and ends with the cancellation contract, because deciding who may stop a generation is the part that does not fall out of the code.
+
+## Stream from the server
+
+A `"use server"` function can be an async generator; the transport sends each yielded value over the open response as it is produced, as [Reads, streams, and live data](/building-apps/server-functions/reads-and-live-data#return-a-stream) describes.
+Read the request's abort signal and hand it to the model call:
+
+```ts
+// src/data/assistant.ts
+import { getRequestEvent } from "@solidjs/web";
+
+export async function* answer(question: string) {
+ "use server";
+ const signal = getRequestEvent()?.request.signal;
+ const stream = await model.stream({ prompt: question, signal });
+ for await (const chunk of stream) {
+ yield chunk.text;
+ }
+}
+```
+
+`model.stream` is a stand-in for the provider SDK; provider SDKs take an `AbortSignal` for a streaming call under a name such as `signal` or `abortSignal`.
+The parts that are Solid's are exact: [`getRequestEvent()`](/reference/solid-web/request-response/get-request-event) returns the request event on the server, and `request` is the standard `Request`, whose `signal` aborts when the client disconnects before the response has ended.
+
+Call `answer("Do you ship to Portugal?")` from the browser and the first words arrive before the model has finished the sentence.
+Each `yield` is one chunk on the wire; the function's `for await` loop is the only buffering there is.
+
+:::caution[The signal is the host's to keep live]
+`request.signal` fires on a dropped connection only when the server integration aborts it when the socket closes.
+The Vite plugin's dev server does; a hand-written Node server must abort the controller on the response's `close` event, or the signal never fires and the model keeps generating for a reader that is gone.
+:::
+
+## Collect the chunks in a store
+
+In the browser, [`createProjection`](/reference/solid-js/stores/create-projection) with an async generator turns the stream into a store.
+Each `yield` commits the writes made to the draft since the previous one, so the component reads `reply.text` and sees it grow:
+
+```tsx
+// src/pages/Assistant.tsx
+import { Errored, Show, createProjection, createSignal } from "solid-js";
+import { invoke } from "@solidjs/web/server-functions";
+import { answer } from "../data/assistant";
+
+type Reply = { text: string; done: boolean };
+
+function Assistant() {
+ const [question, setQuestion] = createSignal("");
+ let current: AbortController | undefined;
+
+ const reply = createProjection(
+ async function* (draft: Reply) {
+ const asked = question();
+ draft.text = "";
+ draft.done = !asked;
+ yield;
+ if (!asked) return;
+
+ const controller = (current = new AbortController());
+ try {
+ const chunks = await invoke(
+ answer,
+ { signal: controller.signal },
+ asked
+ );
+ for await (const chunk of chunks) {
+ draft.text += chunk;
+ yield;
+ }
+ } catch (error) {
+ if (!controller.signal.aborted) throw error;
+ }
+ draft.done = true;
+ },
+ { text: "", done: true },
+ { seedLoadingValue: true }
+ );
+
+ return (
+ <>
+
+ (
+
+ {String(error())}
+
+ )}
+ >
+
{reply.text}
+
+ Writing…
+
+
+ >
+ );
+}
+```
+
+Submit a question and the paragraph fills in chunk by chunk while "Writing…" shows; when the generator finishes, `done` flips and the indicator goes away.
+Submit a second question while the first is still streaming and the text clears and starts again with the new answer; nothing from the first answer arrives afterwards.
+
+What happens on that second submit is the whole cancellation story on the client side.
+The projection's function read `question()`, so the new value re-runs it.
+A re-run supersedes the previous flight: the runtime calls `return()` on the previous generator, which exits its `for await` loop, which in turn closes the server function's iterable and aborts the request.
+Draft writes the old generator makes after that point are ignored, which is why a late chunk from the first answer cannot land in the second.
+
+The `seedLoadingValue` option makes the seed, `{ text: "", done: true }`, the store's first committed value, so the first render shows an empty reply and no [`Loading`](/concepts/boundaries) boundary is needed above the paragraph.
+
+### Two ways to stop
+
+The second submit stops the first answer by superseding the read.
+That is the shape to prefer: there is no controller to keep, and anything that re-runs the projection, including a `refresh(reply)`, cancels the same way.
+
+The Stop button is the explicit form.
+[`invoke()`](/reference/solid-web/server-functions/invoke) calls a server function reference with options that belong to one call, and `signal` is one of them: aborting it rejects the call and cancels the request.
+The `catch` turns that rejection into a normal end when the abort was ours, so the text so far stays on screen and `done` flips; any other failure is rethrown.
+Without the `catch`, Stop would be an error the boundary shows, which is not what the shopper asked for.
+
+The controller lives in a plain variable rather than a signal on purpose: assigning it is not a reactive write, so the projection's function may do it, and nothing renders from it.
+
+### Errors and retry
+
+A rejected model call, a network failure after the first chunk, or an exception in the generator ends the projection's flight with an error, and the nearest [`Errored`](/reference/solid-js/components-jsx/errored) boundary renders its fallback.
+The fallback's `reset` re-runs the sources that failed, so Retry asks the same question again with no state to clear by hand.
+
+## Render Markdown without re-rendering the answer
+
+A model answers in Markdown, and parsing the whole text on every chunk gets slower as the answer grows and rebuilds DOM the reader is looking at.
+Split the answer into completed blocks and one trailing block: a completed block never changes, so it is parsed once and keyed; only the trailing block is re-rendered per chunk.
+
+```tsx
+// src/pages/Assistant.tsx
+import { For, createProjection, createSignal } from "solid-js";
+import { answer } from "../data/assistant";
+
+type Block = { id: number; source: string };
+type Reply = { blocks: Block[]; tail: string; done: boolean };
+
+function Assistant() {
+ const [question, setQuestion] = createSignal("");
+ let nextId = 0;
+
+ const reply = createProjection(
+ async function* (draft: Reply) {
+ const asked = question();
+ draft.blocks = [];
+ draft.tail = "";
+ draft.done = !asked;
+ yield;
+ if (!asked) return;
+
+ for await (const chunk of await answer(asked)) {
+ draft.tail += chunk;
+ const parts = draft.tail.split("\n\n");
+ draft.tail = parts.pop() ?? "";
+ for (const source of parts) draft.blocks.push({ id: nextId++, source });
+ yield;
+ }
+ if (draft.tail) draft.blocks.push({ id: nextId++, source: draft.tail });
+ draft.tail = "";
+ draft.done = true;
+ },
+ { blocks: [], tail: "", done: true },
+ { seedLoadingValue: true }
+ );
+
+ return (
+
+ block.id}>
+ {(block) => }
+
+
+
+ );
+}
+```
+
+`renderMarkdown` is a stand-in for the Markdown library of your choice; sanitize its output before using `innerHTML` on text a model produced.
+A blank line closes a block, which is a good enough boundary for paragraphs, headings, and lists; a parser that reports where a block ends can replace the `split`.
+
+Ask a long question and watch the DOM: each completed paragraph is inserted once and never touched again, and only the last `div` changes as chunks arrive.
+`For` keyed by `id` keeps a block's element when the array is replaced, for example when the finished answer is written back from a saved conversation, and `push` on the draft adds a row without rebuilding the others.
+
+## The cancellation contract
+
+Three parties can stop a generation, and the code above connects them in one direction.
+State the contract in the server function's comment, because a reader of either side cannot see the other:
+
+1. The browser aborts: a superseding read, a Stop button, or a closed tab ends the iteration or the request.
+2. The server's `request.signal` aborts, and the runtime calls `return()` on the generator, so its `finally` blocks run.
+3. The generator forwarded that signal to the model call, so the provider stops generating.
+
+Step 3 is the one the author decides.
+A generator that does not pass the signal on still ends, but only at its next `yield`, after the model has produced one more chunk for nobody; a model call that runs to completion for a reader who left is the cost of forgetting it.
+
+Forwarding is right for a chat answer, where the shopper is the only consumer and an abandoned answer has no value.
+It is wrong for a durable job: a report the shopper asked to be generated into their account should finish whether or not they stay on the page, and reloading the page should show its progress rather than start it again.
+Do not forward the signal to such a job.
+Start it with an action, store its output on the server as it is produced, expose progress as a [live source](/building-apps/server-functions/reads-and-live-data#declare-a-live-source) the page reads, and expose Stop as a second action that the job checks between steps.
+In that shape the request's abort means only "this reader left", which is the correct meaning; the job's own lifetime belongs to the server.
+
+:::note[What a superseded read cannot do]
+Cancellation from the reactive graph stops the stream the component was reading.
+It cannot stop work the server function started and did not tie to the signal, such as a job enqueued before the first `yield`.
+If the function has side effects beyond the stream, give them the server-owned shape above.
+:::
+
+## Common problems
+
+### The old answer's chunks appear after a new question
+
+The generator reads the question after its first `await`, so the re-run tracked nothing and the first flight was never superseded.
+Read every reactive input before the first `await` or `yield`, as `asked` is read above.
+
+### Stop shows the error fallback
+
+The abort's rejection reached the boundary.
+Catch it in the generator and rethrow only when `controller.signal.aborted` is false.
+
+### The model keeps running after the tab closes
+
+`request.signal` never fired.
+Check that the server integration aborts the request on a closed socket, and that the generator passes the signal to the model call rather than only reading it.
+
+### The fallback flashes before the first chunk
+
+The projection was read under a `Loading` boundary without `seedLoadingValue`, so the seed was not observable until the first `yield` landed, and the boundary showed its fallback for that window.
+Pass `{ seedLoadingValue: true }` when the seed is a valid first render, or keep the boundary and accept the fallback.
+
+## Recap
+
+- Return an async generator from a `"use server"` function and pass `getRequestEvent()?.request.signal` to the model call.
+- Consume the stream in `createProjection(async function* (draft) { ... })`; each `yield` commits the draft writes made since the last one.
+- Read every reactive input before the first `await` so a change supersedes the flight, which closes the previous generator and aborts its request.
+- Use `invoke(fn, { signal }, ...args)` for an explicit Stop, and catch the abort so the text so far stays on screen.
+- Put completed Markdown blocks in a `For` keyed by `id` and re-render only the trailing block.
+- Forward the signal when the reader is the only consumer; for a durable job, do not forward it, and expose Stop as an action.
+
+## Next steps
+
+- [Reads, streams, and live data](/building-apps/server-functions/reads-and-live-data): the transport behind the stream, and `live()` for a value that replaces itself over time.
+- [Metadata and transport](/building-apps/server-functions/metadata-and-transport#configure-one-call): the other per-call options `invoke()` accepts, and what it refuses.
+- [Mutations](/concepts/mutations): actions and optimistic stores, for the server-owned job shape.
+- [Chat state for the AI SDK](/guides/ai-sdk-chat): the same ideas through the AI SDK's chat class and a store-backed adapter.
From c766f416cb3aaf7cf5592b1b0f580eba74c69c47 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 15 Oct 2026 15:55:17 +0000
Subject: [PATCH 4/6] docs(guides): add "Chat state for the AI SDK"
A recipe for a store-backed ChatState adapter for the AI SDK's
AbstractChat (ai 7.x; @ai-sdk/solid is deprecated): status and error in
signals, messages in a store, push and pop on the draft, replaceMessage
through reconcile so rows keep their identity, and snapshot as
structuredClone(snapshot(thing)) because the stream processor keeps
mutating the object it hands over. No throttle. A component that renders
it with a keyed For, and the API route on the server.
The ChatState interface is quoted from packages/ai/src/ui/chat.ts on
vercel/ai main; the adapter, the component and the route type-check
against ai@7.0.141 and the built Solid 2.0 types.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY
---
src/routes/(5)guides/(18)ai-sdk-chat.mdx | 238 +++++++++++++++++++++++
1 file changed, 238 insertions(+)
create mode 100644 src/routes/(5)guides/(18)ai-sdk-chat.mdx
diff --git a/src/routes/(5)guides/(18)ai-sdk-chat.mdx b/src/routes/(5)guides/(18)ai-sdk-chat.mdx
new file mode 100644
index 000000000..14f575b7e
--- /dev/null
+++ b/src/routes/(5)guides/(18)ai-sdk-chat.mdx
@@ -0,0 +1,238 @@
+---
+title: "Chat state for the AI SDK"
+version: "2.0"
+description: "Drive the AI SDK's chat class from a Solid store with a short ChatState adapter, so messages, status, and errors update the exact nodes that read them."
+---
+
+You have an API route that answers `POST /api/chat` with `streamText(...).toUIMessageStreamResponse()` from the `ai` package, and you want the chat screen in Solid.
+The package's own framework adapters hold messages in their framework's state, and `@ai-sdk/solid` is deprecated: its README says so, names AI SDK 5 as the release that removes it, and its last version stops at the `useChat` hook of that era.
+
+Nothing in the chat logic depends on a framework, though.
+`AbstractChat` in `ai` does the transport, the stream parsing, tool calls, and status; it reads and writes its messages through a small `ChatState` interface that the adapter supplies.
+This recipe is that adapter on a Solid store, about sixty lines, with no throttle, because a store update already touches only the nodes that read the changed value.
+
+This recipe targets `ai` 7.x; the interface is quoted from its source.
+Install the package with your package manager, no `@ai-sdk/*` UI package is needed.
+
+## The interface to implement
+
+`ChatState` has seven members:
+
+```ts
+export interface ChatState {
+ status: ChatStatus;
+ error: Error | undefined;
+ messages: UI_MESSAGE[];
+ pushMessage: (message: UI_MESSAGE) => void;
+ popMessage: () => void;
+ replaceMessage: (index: number, message: UI_MESSAGE) => void;
+ snapshot: (thing: T) => T;
+}
+```
+
+`AbstractChat` sets `status` and `error` as the request moves through `submitted`, `streaming`, `ready`, and `error`, reads `messages` to build the next request, and writes through `pushMessage`, `popMessage`, and `replaceMessage` as chunks arrive.
+`snapshot` must return a copy it can mutate: the stream processor keeps one working message object and hands it to `replaceMessage` after every chunk.
+
+## The adapter
+
+```ts
+// src/lib/chat.ts
+import {
+ AbstractChat,
+ type ChatInit,
+ type ChatState,
+ type ChatStatus,
+ type UIMessage,
+} from "ai";
+import { createSignal, createStore, reconcile, snapshot } from "solid-js";
+
+class SolidChatState implements ChatState {
+ #messages = createStore([]);
+ #status = createSignal("ready");
+ #error = createSignal();
+
+ constructor(initial: M[] = []) {
+ this.messages = initial;
+ }
+
+ get status() {
+ return this.#status[0]();
+ }
+ set status(value: ChatStatus) {
+ this.#status[1](value);
+ }
+
+ get error() {
+ return this.#error[0]();
+ }
+ set error(value: Error | undefined) {
+ this.#error[1](value);
+ }
+
+ get messages() {
+ return this.#messages[0];
+ }
+ set messages(value: M[]) {
+ this.#messages[1](reconcile(value.map((m) => this.snapshot(m))));
+ }
+
+ pushMessage = (message: M) => {
+ this.#messages[1]((draft) => {
+ draft.push(this.snapshot(message));
+ });
+ };
+
+ popMessage = () => {
+ this.#messages[1]((draft) => {
+ draft.pop();
+ });
+ };
+
+ replaceMessage = (index: number, message: M) => {
+ const copy = this.snapshot(message);
+ this.#messages[1](
+ reconcile(this.messages.map((m, i) => (i === index ? copy : m)))
+ );
+ };
+
+ snapshot(thing: T): T {
+ return structuredClone(snapshot(thing));
+ }
+}
+
+export class Chat extends AbstractChat {
+ constructor({ messages, ...init }: ChatInit = {}) {
+ super({ ...init, state: new SolidChatState(messages) });
+ }
+}
+```
+
+Three decisions in that file are worth knowing about.
+
+`status` and `error` are signals and `messages` is a store, so a component that reads `chat.status` re-runs only when the status changes, and a component that reads one message's text re-runs only when that text changes.
+The stream processor calls `replaceMessage` once per chunk with a message whose last text part has grown.
+Assigning that object into the array slot would replace the row and re-run every reader of it, so the adapter passes the array through [`reconcile`](/reference/solid-js/stores/reconcile) instead: rows match by `id` and keep their proxies, the unchanged rows are skipped by reference, and inside the changed row only the `text` leaf that differs notifies its readers.
+
+`pushMessage` and `popMessage` write the draft directly, because adding and removing a row is what `push` and `pop` do on a store without touching the other rows.
+
+`snapshot` copies twice on purpose.
+Solid's [`snapshot`](/reference/solid-js/advanced/store-advanced/snapshot) turns a store proxy into plain data, which `structuredClone` can then copy; a plain object passes through the first call unchanged and is copied by the second.
+The copy matters in `replaceMessage` as much as in `snapshot`: the stream processor goes on mutating the message object it handed over, and a store that adopted that same object as its backing would see the data change underneath it with no write to track.
+
+:::note[No throttle]
+The framework adapters that ship with the AI SDK accept a `throttleWaitMs` that batches message updates for a view that re-renders on each one.
+A Solid store update is per key, so there is no view to re-render and nothing to throttle.
+If a model produces chunks faster than the screen can paint, the writes are already batched to a microtask.
+:::
+
+## Use it in a component
+
+```tsx
+// src/pages/Support.tsx
+import { For, Show } from "solid-js";
+import { DefaultChatTransport, type UIMessage } from "ai";
+import { Chat } from "../lib/chat";
+
+function text(part: UIMessage["parts"][number]) {
+ return part.type === "text" ? part.text : "";
+}
+
+function Support() {
+ const chat = new Chat({
+ transport: new DefaultChatTransport({ api: "/api/chat" }),
+ });
+
+ return (
+ <>
+
}
+
+ >
+ );
+}
+```
+
+Type a question and send it.
+The user message appears, the input disables while `status` is `submitted` and then `streaming`, and the assistant's reply fills in as the stream arrives; press Stop and the reply keeps what has landed, and `status` returns to `ready`.
+
+`chat.messages` is the store, so the `For` reads it as a reactive list; keyed by `id`, a message's `li` survives every chunk, and only the `span` whose part text changed updates.
+`text(part)` is called inside the JSX expression, so the read of `part.text` is tracked there; reading it at the top of the callback would freeze it at the first chunk.
+`chat.status` and `chat.error` read the signals through the getters on `AbstractChat`, which is why `disabled` and the error paragraph follow them.
+
+The `Chat` instance is created in the component body, which gives its signals and store an owner, so they are disposed with the component.
+
+## The server side
+
+The route is the AI SDK's own and has nothing Solid-specific in it; this is the shape that [API routes](/building-apps/middleware-and-api-routes#api-routes) take with the filesystem router:
+
+```ts
+// src/routes/api/chat.ts
+import type { APIHandler } from "filesystem-routing/api";
+import { convertToModelMessages, streamText, type UIMessage } from "ai";
+
+export const POST: APIHandler = async ({ request }) => {
+ const { messages }: { messages: UIMessage[] } = await request.json();
+ const result = streamText({
+ model: provider("your-model"),
+ messages: await convertToModelMessages(messages),
+ abortSignal: request.signal,
+ });
+ return result.toUIMessageStreamResponse();
+};
+```
+
+`provider` is a stand-in for the model provider package you install.
+Passing `request.signal` as `abortSignal` is the same cancellation contract as in [Streaming AI responses](/building-apps/server-functions/streaming-ai-responses#the-cancellation-contract): `chat.stop()` aborts the fetch, the request's signal fires on the server, and the model call stops.
+
+## What you give up
+
+`AbstractChat` reads and writes every message through the adapter, so the adapter is the whole integration, but the chat is not a Solid computation.
+It does not participate in a `Loading` boundary, `isPending` does not report its requests, and a navigation during a stream does not hold the frame; `status` is the only progress signal.
+For a chat that must take part in the page's async coordination, read the stream through a projection as the streaming guide does, and use the AI SDK only on the server.
+
+## Recap
+
+- `AbstractChat` from `ai` 7.x takes a `ChatState` with `status`, `error`, `messages`, `pushMessage`, `popMessage`, `replaceMessage`, and `snapshot`.
+- Hold `status` and `error` in signals and `messages` in a store; push and pop on the draft, and replace through `reconcile` so rows keep their identity.
+- `snapshot` returns a copy, and `replaceMessage` stores a copy too, because the stream processor keeps mutating the object it hands over.
+- No throttle: a store write touches only the nodes that read the changed key.
+- Create the `Chat` in the component body so its reactive state has an owner.
+- `@ai-sdk/solid` is deprecated; this adapter replaces it.
+
+## Next steps
+
+- [Streaming AI responses](/building-apps/server-functions/streaming-ai-responses): the same stream as a projection, with the cancellation contract spelled out.
+- [Stores](/concepts/stores): drafts, `snapshot`, and why a keyed list keeps its rows.
+- [Middleware and API routes](/building-apps/middleware-and-api-routes): where the `/api/chat` route lives and what the handler receives.
From d27a05a562800e3ba2f137b9971788272a3de676 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 15 Oct 2026 16:13:37 +0000
Subject: [PATCH 5/6] docs(server-functions): add "Durable generation"
A guide for an AI reply generated as a job that outlives the request:
the store, the job registry and the start/stop actions, delivery as
data through live(GET(async function*)) into a projection, the
optimistic bubble held with until over the live thread's ids, the
thread as a live server component (marked experimental), and what a
reconnect resumes from (Last-Event-ID, the digest skip, and where a
partial-replay log would sit).
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY
---
.../(7)durable-generation.mdx | 437 ++++++++++++++++++
1 file changed, 437 insertions(+)
create mode 100644 src/routes/(3)building-apps/(3)server-functions/(7)durable-generation.mdx
diff --git a/src/routes/(3)building-apps/(3)server-functions/(7)durable-generation.mdx b/src/routes/(3)building-apps/(3)server-functions/(7)durable-generation.mdx
new file mode 100644
index 000000000..81353df7f
--- /dev/null
+++ b/src/routes/(3)building-apps/(3)server-functions/(7)durable-generation.mdx
@@ -0,0 +1,437 @@
+---
+title: "Durable generation"
+version: "2.0"
+description: "Run an AI reply as a job that outlives the request that started it, store the reply as it grows, and deliver it through a live source so a closed tab loses nothing and two tabs read the same reply."
+---
+
+The support assistant from [Streaming AI responses](/building-apps/server-functions/streaming-ai-responses) answers while the shopper watches, and stops when they leave.
+That is the right contract for a quick question.
+It is the wrong one for a summary of a forty-message order dispute: the shopper closes the tab to find the order number, comes back, and the summary starts over, because the request that was writing it ended with the tab.
+
+The difference is who owns the generation.
+In the streaming guide the request owns it, and the reactive graph cancels it.
+On this page a job on the server owns it: an action starts the job and returns, the job writes the reply into storage as the model produces it, and every page that shows the reply reads what is stored through a live source.
+Pick the request-scoped shape when the reader is the only consumer and an abandoned answer has no value; pick this shape when the reply must finish without its reader, be found again on reload, or be read from more than one tab.
+
+Two deliveries are shown.
+The client-owned one sends the reply as data through [`live()`](/reference/solid-web/server-functions/live) and renders it in the browser.
+The server-owned one renders the whole thread on the server as a live server component, which is the shape of the `chat` example in the Solid repository; server components are an experimental preview, and that section says so.
+The job, the storage, and the actions are the same in both.
+
+## The job
+
+Three modules carry the shape: a store the job writes into, a registry of running jobs that no request owns, and the actions that start and stop one.
+
+The store keeps each reply and tells watchers when it changes:
+
+```ts
+// src/server/replies.ts
+export type Reply = {
+ id: string;
+ text: string;
+ status: "generating" | "complete" | "stopped" | "failed";
+ error?: string;
+};
+
+const replies = new Map();
+const waiters = new Map void>>();
+
+export function write(id: string, patch: Partial) {
+ const current = replies.get(id)!;
+ replies.set(id, { ...current, ...patch, version: current.version + 1 });
+ const waiting = waiters.get(id);
+ waiters.delete(id);
+ waiting?.forEach((wake) => wake());
+}
+
+function changed(id: string, signal?: AbortSignal) {
+ return new Promise((wake) => {
+ (waiters.get(id) ?? waiters.set(id, new Set()).get(id)!).add(wake);
+ signal?.addEventListener("abort", () => wake(), { once: true });
+ });
+}
+
+// The reply as it stands now, then again after every write.
+export async function* watch(id: string, signal?: AbortSignal) {
+ let seen = -1;
+ while (!signal?.aborted) {
+ const current = replies.get(id);
+ if (current && current.version !== seen) {
+ seen = current.version;
+ yield current as Reply;
+ continue;
+ }
+ await changed(id, signal);
+ }
+}
+```
+
+`watch` checks the version after every yield before it waits, so a write that lands while the consumer holds the previous value is not missed.
+The `chat` example keeps the same shape in `src/server/db.ts`, written through to disk so a reply survives a restart.
+
+The job reads from the model and writes into the store.
+It belongs to the module, not to a request:
+
+```ts
+// src/server/generations.ts
+import * as replies from "./replies";
+
+const running = new Map();
+
+export function start(replyId: string, history: Message[]) {
+ const abort = new AbortController();
+ running.set(replyId, abort);
+ void (async () => {
+ let text = "";
+ try {
+ for await (const chunk of model.stream({
+ history,
+ signal: abort.signal,
+ })) {
+ text += chunk.text;
+ replies.write(replyId, { text });
+ }
+ replies.write(replyId, { status: "complete" });
+ } catch (error) {
+ if (abort.signal.aborted) replies.write(replyId, { status: "stopped" });
+ else
+ replies.write(replyId, {
+ status: "failed",
+ error: "The model stopped answering",
+ });
+ } finally {
+ running.delete(replyId);
+ }
+ })();
+}
+
+export function stop(replyId: string) {
+ running.get(replyId)?.abort();
+}
+```
+
+`model.stream` is a stand-in for the provider SDK, as in the streaming guide.
+The signal it receives is the job's own, not a request's: Stop aborts it, and nothing else does.
+A reply that fails keeps the text that had arrived, with the reason beside it; a reply that was stopped keeps its text and says so.
+Write at most every few tens of milliseconds when the model is fast, as the example does, since every write wakes every watcher.
+
+The actions start and stop a job and return at once:
+
+```ts
+// src/data/replies.ts
+import { action } from "@solidjs/router";
+import * as generations from "../server/generations";
+import * as replies from "../server/replies";
+import * as threads from "../server/threads";
+
+export const sendMessage = action(
+ async (threadId: string, messageId: string, text: string) => {
+ "use server";
+ const { replyId, history } = await threads.append(
+ threadId,
+ messageId,
+ text
+ );
+ generations.start(replyId, history);
+ },
+ "sendMessage"
+);
+
+export const stopReply = action(async (replyId: string) => {
+ "use server";
+ generations.stop(replyId);
+}, "stopReply");
+```
+
+`sendMessage` stores the shopper's message and an empty reply with status `generating`, starts the job, and returns.
+The response closes while the job is still writing; the server-function runtime does not dispose work a function started, so the job runs to its end whether or not anyone is watching.
+`threads.append` is the thread half of the store, which keeps the message list and the history the model reads; the `chat` example's `db.send` is one.
+Both actions are Solid Router [actions](/routing/solid-router/data#mutate-with-actions), so they work as a form before JavaScript has loaded and from code through `useAction`.
+
+:::caution[An orphan is a fact, not a hang]
+A server restart ends every running job and leaves the replies they were writing at `generating`.
+Mark them `failed` with a reason when the store loads, as the `chat` example does, so a reader sees a message with a Retry rather than a reply that never finishes.
+:::
+
+## Client-owned: the browser renders the reply
+
+The live source yields the reply so far, whole, on every write:
+
+```ts
+// src/data/replies.ts
+import { getRequestEvent } from "@solidjs/web";
+import { GET, live } from "@solidjs/web/server-functions";
+import * as replies from "../server/replies";
+
+export const watchReply = live(
+ GET(async function* (replyId: string) {
+ "use server";
+ const signal = getRequestEvent()?.request.signal;
+ for await (const reply of replies.watch(replyId, signal)) yield reply;
+ })
+);
+```
+
+The request's signal ends the watcher when the reader leaves; the job does not see it.
+The first yield on every connection is the current reply, which is the contract `live()` reconnects on, as [Declare a live source](/building-apps/server-functions/reads-and-live-data#declare-a-live-source) describes.
+
+In the browser, [`createProjection`](/reference/solid-js/stores/create-projection) with an async generator turns the stream into a store:
+
+```tsx
+// src/pages/Reply.tsx
+import { Show, createProjection } from "solid-js";
+import { stopReply, watchReply } from "../data/replies";
+
+type ReplyView = { text: string; status: string; error?: string };
+
+function Reply(props: { replyId: string }) {
+ const reply = createProjection(
+ async function* (draft: ReplyView) {
+ for await (const next of watchReply(props.replyId)) {
+ draft.text = next.text;
+ draft.status = next.status;
+ draft.error = next.error;
+ yield;
+ }
+ },
+ { text: "", status: "generating" },
+ { seedLoadingValue: true }
+ );
+
+ return (
+
+
{reply.text}
+
+
+
+
+
{reply.error}
+
+
+ );
+}
+```
+
+Send a message, close the tab while the reply is being written, and open the page again.
+The component calls `watchReply` again, the first yield is the reply as far as it had got, and the paragraph keeps growing from there.
+Open the page in two tabs and both show the same reply; press Stop in one and the status changes in both, because Stop aborted the job and the job wrote `stopped` into the store every watcher reads.
+
+The projection reads `props.replyId` before its first `await`, so a different reply re-runs it, which ends the previous iteration and its connection.
+A projection rather than a memo keeps readers of `reply.status` out of every text update, and gives the Markdown block split from [Render Markdown without re-rendering the answer](/building-apps/server-functions/streaming-ai-responses#render-markdown-without-re-rendering-the-answer) a draft to write into.
+`refresh(reply)` re-runs the projection, which opens a new connection; it does not touch the job.
+
+Every call site that iterates a live source holds its own connection.
+When the thread list and the thread both need the same reply, hoist the read, or declare it with Solid Router's [`liveQuery`](/reference/solid-router/data#livequery), which keeps one connection per key for every consumer; the spelling is `liveQuery(watchReply, "reply")`, and a function already declared with `live(GET())` passes through unchanged.
+
+## Hold the bubble until the stream confirms
+
+The shopper's own message should be on screen the moment they press Enter, and should give way to the server's row when the thread shows it.
+The thread is read through a live source, so the confirmation arrives on the stream rather than in the action's response.
+That is what [`until`](/reference/solid-js/lifecycle-actions/until) waits for:
+
+```tsx
+// src/components/Composer.tsx
+import { For, action, createOptimisticStore, until } from "solid-js";
+import { useAction } from "@solidjs/router";
+import { sendMessage } from "../data/replies";
+
+type Pending = { id: string; text: string };
+
+function Composer(props: { threadId: string; seen: string[] }) {
+ const send = useAction(sendMessage);
+ const [outbox, setOutbox] = createOptimisticStore([]);
+
+ const submit = action(function* (text: string) {
+ const id = crypto.randomUUID();
+ setOutbox((list) => {
+ list.push({ id, text });
+ });
+ yield send(props.threadId, id, text);
+ yield until(() => props.seen.includes(id), { timeout: 15_000 });
+ });
+
+ return (
+ <>
+ !props.seen.includes(m.id))}>
+ {(message) =>
{message.text}
}
+
+
+ >
+ );
+}
+```
+
+`seen` is the list of message ids the live thread shows; the parent reads it from the thread's live source and passes it down.
+The push onto `outbox` is an optimistic write: it shows at once and is held by the action's transaction.
+`until` reads the authoritative `seen`, so the optimistic row cannot satisfy it; only the stream can.
+When the row arrives, `until` resolves, the action settles, and the overlay drops.
+A timeout or a server error rejects the action, which drops the overlay the same way and leaves the shopper's text to send again.
+
+The `For` also filters the outbox by `seen`.
+The action's overlay is released a task after the data that satisfied its `until` lands, so without the filter a frame can show the bubble and the row together; deriving the bubble's visibility from `seen` closes that gap, and `until` keeps its job of settling the action on failure and timeout.
+
+`action` here is the one from `solid-js`, which runs a generator as one transaction; `sendMessage` is a Solid Router action, bound to the router with `useAction` so a call from code carries its revalidation and submission tracking.
+
+## Server-owned: the thread as a live server component
+
+:::caution[Server components are an experimental preview]
+A function returned from a `"use server"` function is a server component, mounted in the browser with `dynamicComponent`.
+The runtime ships in `@solidjs/web` and is excluded from Solid 2.0's stability guarantees: the API and the wire format may change between prereleases.
+The `chat` example in the Solid repository is this section's reference implementation.
+:::
+
+When the markdown renderer, the highlighter, and the model client should stay on the server, the thread can be server markup that keeps changing.
+The server function returns a component instead of data, and `live()` holds the response open while it re-renders:
+
+```tsx
+// src/routes/thread.tsx
+import { createMemo, onCleanup } from "solid-js";
+import { getRequestEvent } from "@solidjs/web";
+import { GET, live } from "@solidjs/web/server-functions";
+import type { Slot } from "@solidjs/web/frames";
+import { liveQuery } from "@solidjs/router";
+import { stopReply } from "../data/replies";
+import * as threads from "../server/threads";
+
+type ComposerSlot = Slot<{ threadId: string; seen: string[] }>;
+
+export const getThread = liveQuery(
+ live(
+ GET(async (id: string) => {
+ "use server";
+ return (props: { composer: ComposerSlot }) => {
+ const gone = new AbortController();
+ onCleanup(() => gone.abort());
+ getRequestEvent()?.request.signal.addEventListener("abort", () =>
+ gone.abort()
+ );
+ const view = createMemo(() => threads.watch(id, gone.signal));
+ return (
+
+ {view().messages.map((message) => (
+
+
+ {message.status === "generating" && (
+
+ )}
+
+ ))}
+ m.id)}
+ />
+
+ );
+ };
+ })
+ ),
+ "thread"
+);
+```
+
+`threads.watch` is the thread-level watcher: it yields the thread and its messages after every write to any of them.
+The route component mounts the call and fills the slot:
+
+```tsx
+// src/routes/thread.tsx
+import { Loading } from "solid-js";
+import { dynamicComponent } from "@solidjs/web";
+import type { RouteSectionProps } from "@solidjs/router";
+import Composer from "../components/Composer";
+
+export default function ThreadRoute(props: RouteSectionProps) {
+ const Thread = dynamicComponent(() => getThread(String(props.params.id)));
+ return (
+ Loading the conversation…
}>
+ }
+ />
+
+ );
+}
+```
+
+The composer from the previous section is the slot's fill: the server positions it at the foot of the thread and passes `seen` as a slot argument, so `until` reads the same ids the server rendered.
+`$key={id}` makes a new composer per thread, so a half-typed message stays with its conversation.
+
+What this shape adds over the client-owned one:
+
+- The page's source carries the reply as far as it had got, because the document face renders the live source's first value, and hydration adopts it and connects.
+- The browser bundle has no Markdown parser, no highlighter, and no model client.
+- Every mutation is a form the server rendered, which works before JavaScript loads; a form that has been submitted stays marked busy while the markup around it changes.
+
+What it costs: the server holds one render per open thread per tab, and the thread and the sidebar are two connections per tab, which is why the example serves HTTP/2 in development.
+
+The example reads the thread through one watcher at the top of the render and cuts the markup into the finished messages and the reply being written, so a token re-sends the reply and nothing else.
+A watcher created per message inside the list never settles, because each retry of the render makes a new one.
+
+## Reconnect, and where a reply resumes from
+
+`live()` reconnects a stream that dies after it has connected, with exponential backoff, and the first yield of the new connection replaces whatever the page shows.
+Each event on the wire carries an id, and every reconnect sends the last id it received back as `Last-Event-ID`.
+When that id equals the digest of the source's first yield, the server drops that yield: the client already holds it, so an unchanged reply costs nothing on reconnect.
+For a live server component the reconnect also names the pieces of markup the page shows, and the server sends only the pieces whose digest differs.
+After hydration, the first connection names the value the page was served with, so a server that still holds that value sends nothing.
+
+What this does not do is replay the part of a reply between the last event and now.
+The source yields the whole reply, so a reconnect gets the reply so far in one value, and a long reply costs its length each time.
+Partial replay is a join with the job's log: keep an append-only log of chunks per reply, read the header in the source with `getRequestEvent()?.request.headers.get("last-event-id")`, and yield the chunks after that position.
+A source that reads the header itself is a cursor source; the runtime's digest skip does not apply to it.
+`@tanstack/ai-durable-stream` and Electric's Durable Streams are off-the-shelf logs of that kind, for a team that wants one rather than a table.
+
+## Common problems
+
+### The reply stops when the shopper leaves
+
+The job received the request's signal instead of its own.
+Give the job an `AbortController` the registry owns, and pass `request.signal` only to the watcher.
+
+### Stop does nothing
+
+The Stop action aborted a controller the job was not reading, or the job was started by a server that has since restarted.
+Check that `generations.start` passes `abort.signal` to the model call, and that the store marks orphaned replies `failed` on load.
+
+### Two tabs show different replies
+
+Each tab is reading a copy the job made rather than the store.
+The job writes once, into the store; every page reads the store through its watcher.
+
+### The bubble and the server's row show together for a frame
+
+The bubble leaves when the action settles, which is a task after the row landed.
+Filter the outbox by `seen` as well as waiting on it with `until`.
+
+### `Cannot call until inside a reactive scope`
+
+`until` was called inside a memo or an effect.
+Await it from an action body or another imperative scope.
+
+## Recap
+
+- Start the generation from an action that returns at once; the job belongs to a module-level registry and writes the reply into a store as it arrives.
+- Pass the job its own abort signal; the request's signal ends only the watcher.
+- Deliver the reply through `live(GET(async function*))`, yielding the whole reply so far and the current reply first on every connection.
+- Render it in the browser with `createProjection`, or on the server as a live server component mounted with `dynamicComponent` through `liveQuery(live(GET(fn)), key)`.
+- Hold an optimistic row with `createOptimisticStore` inside an `action`, confirm it with `until` over the live thread's ids, and filter the row by the same ids.
+- A reconnect sends `Last-Event-ID`; an unchanged first value is skipped, and partial replay needs a log the source reads the header against.
+
+## Next steps
+
+- [Streaming AI responses](/building-apps/server-functions/streaming-ai-responses): the request-scoped shape, and the cancellation contract that decides between the two.
+- [Reads, streams, and live data](/building-apps/server-functions/reads-and-live-data): `live()`, its reconnect, and `onstatus`.
+- [Mutations](/concepts/mutations): actions, optimistic stores, and `until`.
+- [Data loading and mutations](/routing/solid-router/data): Solid Router's `action`, `useAction`, and forms.
From e254b7a1e0971946adb935d985fc90a1593d1ac5 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 15 Oct 2026 18:47:28 +0000
Subject: [PATCH 6/6] docs(guides): point the instructions block at AGENTS.md
alone
One root file is what the assistants read; the per-tool copies are not needed.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY
---
src/routes/(5)guides/(17)ai-tools.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/routes/(5)guides/(17)ai-tools.mdx b/src/routes/(5)guides/(17)ai-tools.mdx
index e61f60953..97e97a850 100644
--- a/src/routes/(5)guides/(17)ai-tools.mdx
+++ b/src/routes/(5)guides/(17)ai-tools.mdx
@@ -145,7 +145,7 @@ Give an assistant the index and let it fetch the pages it needs; the full corpus
## Rules to paste into an instructions file
-Drop this block into `AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, or whichever file the assistant reads, and keep it next to the project's own conventions.
+Drop this block into the project's `AGENTS.md`, which Codex, Cursor, Copilot and Gemini CLI read directly and Claude Code reads when there is no `CLAUDE.md`, and keep it next to the project's own conventions.
It holds the three rules that account for most generated bugs, the names that no longer exist, and pointers to the files above.
```md