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, so it composes with the AI engine but installs
independently — you can adopt durable chat without pulling in the engine.
This subpath is published on JSR as part of @netscript/fresh@0.0.7 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. With producer: { id, epoch }, appends are fenced against stale writers. |
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
});
newMessages accepts readonly NetScriptChatSendMessage[]: server-trusted TanStack UI
messages with parts, Model messages with content and toolCalls, or the existing
{ id, role, content: string } form. Native parts and metadata survive storage unchanged;
Model content is also exposed as replay parts. Validate any client input before this
seam and give the model the same prompt that is persisted. The plugin-AI scaffold
builds its own user turn from message.text and ignores client transcript fields.
resolveChatSnapshot().nativeMessages exposes complete persisted messages, including attachments,
tools, structured data, metadata and opaque future parts. messages and renderParts remain the
reduced rendering projection. Seed and live share the native replay reducer: native appends become
standard TanStack MESSAGES_SNAPSHOT chunks containing the complete state, while ordinary assistant
chunks pass through. Message IDs reconcile text echoes and native batches without duplicating
messages.
import { createNetScriptChatConnection, resolveChatSnapshot } from '@netscript/fresh/ai';
const target = { sessionId: 'chat-session' };
const seed = await resolveChatSnapshot({ target });
const connection = createNetScriptChatConnection({
target,
initialMessages: seed.nativeMessages,
initialOffset: seed.offset ?? undefined,
});
// Also initialize the island's TanStack message state from seed.nativeMessages.
connection.dispose();
Pair the seed's native messages with its offset so the next native snapshot retains history. An offset alone reads only the suffix. Without an offset, the connection replays the complete stored log and follows live updates. Logical readers share one physical SSE; cancelling one leaves its peers active, cancelling the last closes SSE, and disposal cancels all reads and sends. A later subscription on the same connection resumes at the transport cursor with the existing replay state. A reader joining an active subscription receives subsequent chunks only, as before.
Publication, the published-consumer proof and downstream EIS adapter removal with a live one-SSE-per-pane check remain release/adoption steps for #2068.
Fencing a reclaimed chat executor
A chat turn that runs in the background — in a worker or saga under a service identity, not
in a request an open app keeps alive — can be reclaimed: its lease expires, a new claim runs
the turn again, and the old executor may still be writing. Pass producer to fence it.
Every append (the text echoes, complete native newMessages batch, and each assistant
chunk) then carries one
durable-streams idempotent-producer sequence (id, epoch, seq), and the streams service
rejects a writer whose epoch is older than the newest one that has written. Fenced turns
persist the same native message batch as the default path, including non-text parts and
metadata.
import { NetScriptChatProducerError, toNetScriptChatResponse } from "@netscript/fresh/ai";
try {
await toNetScriptChatResponse({
target: { sessionId: turn.sessionId },
newMessages: turn.newMessages,
source: assistantChatStream,
mode: "await", // the executor waits for every append to be acknowledged
// id: stable across retries of this turn. epoch: this claim's generation.
producer: { id: `chat-turn:${turn.sessionId}:${turn.id}`, epoch: claim.generation },
});
} catch (error) {
if (error instanceof NetScriptChatProducerError && error.kind === "stale-epoch") {
// A newer claim (error.currentEpoch) owns this turn; nothing from this writer was stored.
return;
}
throw error;
}
- The caller owns the epoch. NetScript never claims or bumps an epoch:
autoClaimstays off, because re-claiming a stale epoch would defeat fencing. Use a non-negative integer that increases on every claim of the turn: a claim counter or row version your executor increments when it takes the turn. When your job runtime exposes a lease or claim generation, use that. - A replay under the same pair is deduplicated chunk by chunk. Each chunk is sent as
its own producer batch, so its sequence number is its index in the turn (the
text echoes first, then the native batch, then the assistant chunks), however fast the source yields.
Replaying the same turn under the same
(id, epoch)therefore appends only the chunks an earlier call never stored, for example after the executor was interrupted. The service drops an already-stored index without comparing content, so use one id per turn. - A new epoch starts a new sequence. A newer claim rewrites the turn from index
0; whatever an older claim stored before it was fenced stays in the transcript. - One request per chunk. Fenced turns trade batching for that determinism: each chunk is its own request, with at most five in flight.
- Fencing starts once the newer claim has written. Until the new epoch's first append, the service has not seen it, so an older writer is still accepted.
- Errors are typed. A rejected append becomes a
NetScriptChatProducerErrorwhosekinduses the same failure vocabulary as State Protocol producers ('stale-epoch','sequence-gap','stream-closed','retryable', ...). It is thrown in'await'mode or while echoingnewMessages. In the default'immediate'mode a failure after the202is logged, as the unfenced writer does, so executors should use'await'. - Without
producernothing changes. The default (unfenced) NetScript path is used unchanged, with no producer headers.
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 API reference enumerates every exported symbol.