Skip to main content
0.0.x

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

@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. 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: autoClaim stays 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 NetScriptChatProducerError whose kind uses the same failure vocabulary as State Protocol producers ('stale-epoch', 'sequence-gap', 'stream-closed', 'retryable', ...). It is thrown in 'await' mode or while echoing newMessages. In the default 'immediate' mode a failure after the 202 is logged, as the unfenced writer does, so executors should use 'await'.
  • Without producer nothing 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.

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 API reference enumerates every exported symbol.