Skip to main content
0.0.x

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
  5. 5 · MCP tools & widgets
  6. 6 · Live streaming

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.

First give that dynamic route a typed identity. createRouteReference from @netscript/fresh/route infers the { sessionId } path param straight from the [sessionId] pattern, so the id the server reads and the URL the browser posts to in chapter 3 both come from one declaration — never a string assembled two different ways. Put it in the shared contracts/ tree so the route and the island import the same object:

// contracts/routes/chat-turn.ts
import { createRouteReference } from '@netscript/fresh/route';

/** The one place the chat turn route pattern is written. */
export const chatTurnRoute = createRouteReference('/api/chat/[sessionId]');
// chatTurnRoute.parsePath({ sessionId })      -> typed { sessionId: string }
// chatTurnRoute.href({ path: { sessionId } }) -> "/api/chat/<id>"  (the island posts here in ch. 3)

Now the route reads its param through the contract instead of touching ctx.params by hand:

// 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';
import { chatTurnRoute } from '../../../../contracts/routes/chat-turn.ts';

// 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> {
    // Typed off the one route contract — the same object the island builds its URL from.
    const { sessionId } = chatTurnRoute.parsePath(ctx.params);
    const target = { 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:

Aspire assigns the app's port at start-up so several workspaces can run side by side, so take the app URL from the Aspire dashboard and put it in a variable first:

APP=http://localhost:<port shown for your app in the dashboard>

# Run a turn (the assistant reply streams back and is persisted durably).
curl -N -X POST "$APP/api/chat/demo-1"

# Replay: read the same session again — the transcript is still there.
curl -N "$APP/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. Notice the discipline underneath — one agreed message shape (NetScriptChatMessage) flows through the route, and the turn is written to the durable session before anyone renders it, so an accepted reply survives the request that produced it. That "durable delivery" spine is what every later chapter builds on. Next you give it a face — copy the fresh-ui chat components and hydrate an island.