Skip to main content
Alpha

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

@netscript/fresh/ai — the durable-chat surface
NameTypeDescription
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 reducerprojectChatSnapshot. 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.

RenderPart (transport) — @netscript/fresh/ai
NameTypeDescription
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.