Skip to main content
Alpha

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.

  1. 1 · Scaffold
  2. 2 · Durable chat route
  3. 3 · Chat UI
  4. 4 · Server-side tool call

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.

@netscript/fresh/ai — the durable-chat seams used here
NameTypeDescription
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 authorize returns 403.
  • [ ] Re-reading the session through the proxy replays the transcript.
  • [ ] deno task check is 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.