The durable chat route
The backend of a durable chat is two routes and one model call. In this chapter you wire the
session route (runs a model turn, persists it durably, gated by authorize), the one
stream proxy the browser reads through, and the direct model call on @tanstack/ai. When
you finish, a curl can drive a full turn and a second curl replays it — the transcript
is durable.
The four seams
@netscript/fresh/ai gives you one function per job. This chapter uses three of the four;
chapter 3 uses the fourth (createNetScriptChatConnection) in the island.
| Name | Type | Description |
|---|---|---|
toNetScriptChatResponse |
session route |
Turn a server chat stream into a durable session Response; authorize-gated (→ 403 on deny). |
resolveChatSnapshot |
history seed |
Materialize the transcript so far, reduced through projectChatSnapshot — the model's context. |
createNetScriptChatStreamProxy |
the one proxy |
The single durable chat-stream proxy the browser reads through; attaches streams auth server-side. |
Step 1 — The model call
The model layer is wired directly on @tanstack/ai and a provider adapter. chat() returns
an async iterable of stream chunks — exactly the source shape toNetScriptChatResponse
persists. anthropicText() reads ANTHROPIC_API_KEY from the environment, so no key
appears in code:
import { chat } from '@tanstack/ai';
import { anthropicText } from '@tanstack/ai-anthropic';
const adapter = anthropicText('claude-sonnet-4-5');
const source = chat({
adapter,
messages: [{ role: 'user', content: 'Hello!' }],
systemPrompts: ['You are a helpful assistant.'],
});
// `source` is an AsyncIterable of TanStack stream chunks.
Step 2 — The session route
This route runs one turn and persists it. It pulls the transcript so far through
resolveChatSnapshot (so the model has context), calls the model, and hands the stream to
toNetScriptChatResponse — gated by an authorize hook.
// apps/dashboard/routes/api/chat/[sessionId].ts
import { chat } from '@tanstack/ai';
import { anthropicText } from '@tanstack/ai-anthropic';
import { resolveChatSnapshot, toNetScriptChatResponse } from '@netscript/fresh/ai';
// REQUIRED in production — no default allow-all. Replace with your real check
// (session cookie → owner lookup). Returning false denies the turn with a 403.
const authorize = (request: Request, sessionId: string): boolean =>
Boolean(request) && sessionId.length > 0;
export const handler = {
async POST(ctx: { req: Request; params: { sessionId: string } }): Promise<Response> {
const target = { sessionId: ctx.params.sessionId } as const;
// History so far, reduced through the SAME projection the island seeds from.
const snapshot = await resolveChatSnapshot({ target });
const messages = snapshot.messages.map((m) => ({ role: m.role, content: m.content }));
const source = chat({
adapter: anthropicText('claude-sonnet-4-5'),
messages,
systemPrompts: ['You are a helpful assistant.'],
});
return toNetScriptChatResponse({ target, request: ctx.req, authorize, source });
},
};
The user message is appended to the durable session by the island in chapter 3 (via
connection.send), so this route reads it back through resolveChatSnapshot and only needs
to stream the assistant reply. You could instead pass newMessages: [userMessage] to
toNetScriptChatResponse to persist the prompt here — one place, either way, never both.
Step 3 — The one stream proxy
The browser must not hold streams credentials, so it never talks to the durable-streams
service directly. It reads through a single proxy that attaches auth server-side and keeps
every response header accurate. Mount createNetScriptChatStreamProxy once as a catch-all:
// apps/dashboard/routes/api/chat-stream/[...path].ts
import { createNetScriptChatStreamProxy } from '@netscript/fresh/ai';
const proxy = createNetScriptChatStreamProxy({
// The session id is the last path segment: /api/chat-stream/ai/chat/{sessionId}
target: (req) => ({ sessionId: new URL(req.url).pathname.split('/').pop()! }),
});
export const handler = { GET: proxy, POST: proxy };
Verify your progress
With aspire start running, drive one turn against a fresh session id, then replay it:
# Run a turn (the assistant reply streams back and is persisted durably).
curl -N -X POST http://localhost:8010/api/chat/demo-1
# Replay: read the same session again — the transcript is still there.
curl -N http://localhost:8010/api/chat-stream/ai/chat/demo-1
The first call streams a reply; the second shows the durable session already holds it — that is durability, not a re-run. Then type-check:
deno task check
- [ ]
POST /api/chat/{sessionId}streams an assistant reply. - [ ] A denied
authorizereturns403. - [ ] Re-reading the session through the proxy replays the transcript.
- [ ]
deno task checkis clean.
What you built
The durable backend: a session route that runs a model turn and persists it behind a
required authorize gate, and the one proxy the browser reads through. Next you give it a
face — copy the fresh-ui chat components and hydrate an island.