Skip to content

Latest commit

 

History

History
171 lines (137 loc) · 8.2 KB

nextjs.md

File metadata and controls

171 lines (137 loc) · 8.2 KB

Next.js and other Fetch-API frameworks

createRouteHandler(agent, options?) serves an agent from a route file of any framework whose handlers take a Web Request and return a Response: the Next.js App Router, SvelteKit, Remix / React Router, Hono, Bun.serve. It is a thin wrapper over the same session API as lousho dev and the deployed server (Deployment), so the React, Vue and Svelte bindings work against it unchanged. It imports no framework and no node:* module. The vendor/ model prefix chooses the provider; with OpenRouter use openrouter/<vendor>/<model> (e.g. openrouter/openai/gpt-4o-mini).

Next.js App Router

// app/api/agent/[[...path]]/route.ts
import { createAgent, createRouteHandler, memoryStore } from '@lousho/build-ai-agent';

// `store` is what lets a session survive across requests: without one every
// `agent.session({ id })` the handler opens gets a fresh in-memory transcript,
// so a second turn forgets the first and `GET /chat/:id` returns empty.
const agent = createAgent({
  model: 'openai/gpt-4o-mini',
  instructions: 'You are helpful.',
  store: memoryStore(), // lives in the server process; use a durable store on serverless
});

export const { GET, POST } = createRouteHandler(agent, {
  basePath: '/api/agent',
  auth: process.env.AGENT_TOKEN,
  uiMessageStream: true,
});

A route with no auth is public: anyone who can reach it can run your agent and spend your model budget. Pass a bearer token, or a function that checks your own session or cookie:

import { createAgent, createRouteHandler } from '@lousho/build-ai-agent';

declare function isSignedIn(request: Request): Promise<boolean>;

export const { GET, POST } = createRouteHandler(createAgent({ model: 'openai/gpt-4o-mini' }), {
  auth: (request) => isSignedIn(request),
});

Routes

All paths are under basePath (default /api/agent; a trailing slash is accepted, and a request outside the base path is a 404).

Route What it does
POST <basePath> { input, sessionId? }: the turn streamed as SSE. This is what useLoushoAgent({ url }) posts; without a sessionId each turn is a fresh session
POST <basePath>/approvals/:id { approved, note? } or { answer }: the continuation streamed (useLoushoAgent's approvalsUrl)
POST <basePath>/chat { sessionId, input }: the session API of the deployed server
GET <basePath>/chat/:sessionId the session's transcript and pending approvals (a turn paused on an approval is in pending, not yet in messages)
POST <basePath>/chat/:sessionId/approvals/:id decide an approval in a session
POST <basePath>/ui with uiMessageStream: true: the AI SDK useChat endpoint
GET <basePath>/oauth/callback the OAuth provider's redirect after a sign-in; never behind auth (the single-use state protects it)
GET <basePath>/health ok, never behind auth

Sessions are kept by the agent's store (createAgent({ store })), so give the agent one that outlives a serverless invocation when you use sessions. With auth, a session id is bound to the first principal that sends it a turn (reading an id claims nothing), and a different principal gets 403 (LOUSHO_SESSION_FORBIDDEN). That binding is kept in memory, so a new serverless instance forgets it, and then refuses the stored session to every user, its owner too: for an app with more than one user, pass authorizeSession as well, e.g. with ids derived from the caller (${userId}-<suffix without '-'>, as below). See Who may use a session.

POST routes take JSON only (Content-Type: application/json, which useLoushoAgent and useChat send) and refuse a browser request from another origin unless you list it in allowedOrigins: see Cross-site requests.

Who may approve a call. createRouteHandler has no approvers option; approvals are decided by authorizeApproval({ principal, sessionId, approval }). By default (callerOwnsApproval) the user a gated call runs for may approve it, and cannot decide another user's call, so a single-user app that prompts its own user needs nothing. That also means a user can approve their own refund. If an approval must come from someone else, pass authorizeApproval. This is a two-person rule: a supervisor decides, and never on a call that runs for themselves. The supervisor also needs authorizeSession to read the customer's session (GET /chat/:id, to show what they are approving) and to decide in it; by default the session belongs to the customer alone:

import { createAgent, createRouteHandler, memoryStore } from '@lousho/build-ai-agent';
import { jwt } from '@lousho/build-ai-agent/auth';

const agent = createAgent({ model: 'openai/gpt-4o-mini', store: memoryStore() });
const isSupervisor = (principal?: { claims?: Readonly<Record<string, unknown>> }) => principal?.claims?.role === 'supervisor';
// Session ids are `<user id>-<suffix without '-'>`; the owner is everything before the last `-`
// (a prefix match would let `alice` reach `alice-x`'s sessions).
const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0));

export const { GET, POST } = createRouteHandler(agent, {
  auth: jwt({ secret: process.env.JWT_SECRET!, issuer: 'https://auth.example.com', audience: 'agent-api' }),
  // Customers use their own sessions; supervisors may read and decide in any session.
  authorizeSession: ({ principal, sessionId, action }) =>
    (principal !== undefined && ownerOf(sessionId) === principal.id) || (action !== 'chat' && isSupervisor(principal)),
  authorizeApproval: ({ principal, approval }) =>
    isSupervisor(principal) &&
    !(principal?.id === approval.principal?.id && principal?.issuer === approval.principal?.issuer),
});

An error reaches the client as its name and code with a generic message; exposeErrors: true sends the raw text. See Who may decide an approval for the 403 it answers with and the supervisor-in-any-session variant.

If the client goes away while a session turn streams (a closed tab, a dropped connection), the turn still runs to its end on the server and is saved to the session, so a tool that already ran (a refund, an email) is in the transcript and the user's next message can ask about it. Nothing more is written to the closed stream. On a serverless host, pass the platform's waitUntil (for example waitUntil from @vercel/functions) so the function is kept alive until the turn is saved. onDisconnect: 'abort' aborts the turn instead, as before; tool results it already has are still kept.

auth also takes an ordered list of jwt(), oidc(), basic() and apiToken() entries, and the caller it accepts reaches the run as principal: see Route auth and principals.

Client: useLoushoAgent

'use client';
import { useLoushoAgent } from '@lousho/build-ai-agent/react';

export function Chat() {
  const agent = useLoushoAgent({ url: '/api/agent', headers: { Authorization: `Bearer ${token}` } }, { approvalsUrl: '/api/agent/approvals' });
  // agent.messages, agent.send(text), agent.approve() ...
}

Client: useChat

With uiMessageStream: true, point useChat at the ui route. The chat id names the session, so only the newest user message is sent to the agent (AI SDK UI).

import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

// ai 6/7 signature; on ai 4 (also a supported peer) use useChat({ api: '/api/agent/ui' })
const { messages, sendMessage } = useChat({ transport: new DefaultChatTransport({ api: '/api/agent/ui' }) });

Other frameworks

The same handler, mounted where the framework wants it:

import { createAgent, createRouteHandler } from '@lousho/build-ai-agent';

const { handler } = createRouteHandler(createAgent({ model: 'openai/gpt-4o-mini' }), { basePath: '/agent', auth: 'secret' });

// SvelteKit: src/routes/agent/[...path]/+server.ts
export const GET = ({ request }: { request: Request }) => handler(request);
export const POST = ({ request }: { request: Request }) => handler(request);

// Hono: app.all('/agent/*', (c) => handler(c.req.raw))
// Bun.serve: fetch: handler