Durable chat
@netscript/fresh/ai is the server + island seam that turns a Fresh route into a
durable AI chat: a chat whose message history and in-flight tool calls survive
reload, reconnect, and multi-tab replay because they are backed by a durable session
stream rather than component state.
alpha
It composes three upstream libraries — @durable-streams/tanstack-ai-transport,
@tanstack/ai-preact, and @tanstack/ai — and adds only NetScript glue: durable-stream
URL resolution, server-side auth headers, and the projection law below. It does not
import @netscript/ai, which is why it ships and installs today, independently of the
beta.2 engine.
This subpath is published on JSR as part of @netscript/fresh and is usable now.
The primitives at a glance
| Name | Type | Description |
|---|---|---|
createNetScriptChatStreamProxy |
(options) => handler |
Build the single durable chat-stream proxy handler — resolves the session target (static or per-request), proxies to the durable-stream URL with server auth, passes the body through unbuffered, and tears down on client abort. |
toNetScriptChatResponse |
(options) => Promise |
Produce a durable-session Response from a server chat stream; enforces authorize (a denial becomes 403) before the session stream is touched. |
resolveChatSnapshot |
(options) => Promise |
Resolve the seed snapshot for SSR / first paint by materializing the session and reducing it through projectChatSnapshot. |
projectChatSnapshot |
(messages) => {messages, renderParts} |
THE single projection reducer — deterministic and side-effect-free. Both seed and live paths MUST route through it (the one-projection law). |
createNetScriptChatConnection |
(options) => NetScriptChatConnection |
Open a live durable session handle: SR2-tolerant subscribe, a send that persists client messages, and one idempotent teardown (close/stop/dispose). |
The one-projection law
resolveChatSnapshot (the SSR/first-paint seed) and the live island projection MUST
run the same reducer — projectChatSnapshot. They are two entry points into one
function applied to one chunk log:
chunks --> [ projectChatSnapshot ] --> { messages, renderParts }
If the seed path and the live path diverge — for example the server hand-rolls a snapshot while the island reduces chunks differently — tool cards drift: a card materialized at seed time renders differently, or vanishes, once the first live chunk arrives, because the two projections disagree about intermediate tool state. This is a hard invariant, not a nicety. Any downstream slice that adds a projection must route both seed and live through it.
The canonical chat-stream proxy
Every durable chat needs one route that proxies the browser's chat-stream request to
the session's durable-stream URL. createNetScriptChatStreamProxy is that route — it
resolves the session target (static or per-request), attaches server-side streams auth
(via getStreamsAuth by default), passes the upstream body through unbuffered,
strips headers that would misdescribe the re-framed bytes, and propagates the client
AbortSignal so a disconnect tears the upstream fetch down. Use it directly as a
Fresh Handlers entry.
// routes/api/chat/[sessionId].ts — the one canonical chat-stream proxy
import { createNetScriptChatStreamProxy } from "@netscript/fresh/ai";
const proxy = createNetScriptChatStreamProxy({
// Resolve the session per request from the route param.
target: (req) => ({
sessionId: new URL(req.url).pathname.split("/").pop()!,
}),
});
export const handler = { POST: proxy, GET: proxy };
NetScriptChatStreamProxyOptions is small: a target (a NetScriptChatSessionTarget
or a (request) => NetScriptChatSessionTarget resolver), an optional auth header
provider (defaults to getStreamsAuth from @netscript/plugin-streams-core), and an
optional fetch override for tests. The returned handler accepts a bare Request or a
Fresh route context (anything with .req), so { POST: handler } and a direct call in
a test both work.
Authorizing the session response
The proxy moves bytes; authorization happens where you produce the session
response. toNetScriptChatResponse sanitizes a server-side chat stream into durable
chunks and returns the session Response. Supply an authorize hook and the matching
request: a denial yields 403 Forbidden and the session stream is never touched.
// The server turn: persist the assistant stream into the durable session, gated by authorize.
import { toNetScriptChatResponse } from "@netscript/fresh/ai";
const response = await toNetScriptChatResponse({
target: { sessionId },
request,
// REQUIRED in production — return false to deny (=> 403).
authorize: (req, id) => sessionBelongsToUser(req, id),
newMessages, // client messages to persist before the assistant turn
source: assistantChatStream, // AsyncIterable of server chat chunks
});
Seeding first paint (SSR)
For SSR and first paint, materialize the session and reduce it through the one
projection reducer with resolveChatSnapshot, then hand the returned offset to the
live subscription so seed and live share one continuous log.
import { resolveChatSnapshot } from "@netscript/fresh/ai";
const snapshot = await resolveChatSnapshot({ target: { sessionId } });
// snapshot.messages -> ordered NetScriptChatMessage[]
// snapshot.renderParts -> transport RenderPart[] (text + tool cards)
// snapshot.offset -> replay cursor for the live subscription (or null if empty)
The live connection
createNetScriptChatConnection opens the live session handle the island subscribes to.
Its subscribe is SR2-tolerant (a first-subscribe that races a not-yet-created stream
re-polls with backoff instead of throwing), send persists client messages, and
close/stop/dispose are one idempotent teardown so no connection leaks.
import { createNetScriptChatConnection } from "@netscript/fresh/ai";
const chat = createNetScriptChatConnection({
target: { sessionId },
authorize: (req, id) => sessionBelongsToUser(req, id), // REQUIRED in prod
initialOffset: snapshot.offset ?? undefined, // continue from the seed cursor
});
try {
for await (const chunk of chat.subscribe(signal)) {
render(chunk);
}
} finally {
chat.dispose();
}
The transport RenderPart
The reducer emits a minimal, transport-level RenderPart — either reduced message
text or a tool card reduced from the same chunk log. This is distinct from the
rich presentation RenderPart owned by the chat UI; the two are
intentionally separate and must not be conflated. The transport part is the stable
contract the UI widens.
| Name | Type | Description |
|---|---|---|
kind |
"text" | "tool" |
Whether this part renders message text or a tool card. |
id |
string |
Stable id of this part within the transcript. |
role |
"system" | "user" | "assistant" | "tool" |
Author role the part belongs to. |
text |
string? |
Reduced text (present when kind === 'text'). |
toolName |
string? |
Invoked tool name (present when kind === 'tool'). |
toolState |
"pending" | "streaming" | "complete" | "error" |
Lifecycle state of the tool card reduced from the chunk log. |
input |
unknown? |
Reduced tool input (may be partial while streaming). |
output |
unknown? |
Reduced tool output, present once complete. |
The message and session shapes are equally small: NetScriptChatMessage
({ id, role, content }), NetScriptChatSessionTarget ({ sessionId, baseUrl?, headers? } — one durable stream per sessionId), and NetScriptChatSnapshot
({ messages, renderParts, offset }).
The ui:// sandbox — @netscript/fresh/ai/sandbox
The sandbox subpath serves themed ui:// resources for MCP-driven UI. Its shipped
piece is createMcpSandboxHandler: a Fresh route handler that serves a registered
ui:// resource selected by ?uri=, injects the active theme's --ns-* custom
properties, stamps data-theme, and applies a per-response CSP derived from the
resource URI. ?theme= selects a token set; an absent or unknown theme falls back to
defaultThemeName (default "default").
// routes/mcp/sandbox.ts
import { createMcpSandboxHandler } from "@netscript/fresh/ai/sandbox";
export const handler = {
GET: createMcpSandboxHandler({
resolveResource: (uri, { signal }) => registry.lookup(uri, { signal }),
themes: {
default: { "--ns-color-surface": "#ffffff" },
dark: { "--ns-color-surface": "#111111" },
},
}),
};
Reference
This page orients; the generated reference enumerates every exported symbol.