Keep the key on your server
Send each request through your own /api/chat — the page names a provider, your server holds the key, calls the model and streams the reply back — with the chat unchanged.
Write the route
Section titled “Write the route”npm install @aparte/core @aparte/provider-openai-compat// app/api/chat/route.ts (Next.js)
import { createAparteChatHandler } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
declare function getSession(req: Request): Promise<{ userId: string } | null>; // your session lookup
export const POST = createAparteChatHandler({
providers: { openai: createOpenAICompatProvider(presets.OPENAI) },
resolveKey: (providerId) => process.env[`${providerId.toUpperCase()}_KEY`],
authorize: async (req) => Boolean(await getSession(req)), // required: this route spends your key
});createAparteChatHandler returns a Fetch handler, Request to Response, for a Next.js route in
the Node runtime, Node, Deno or Bun. It reads { providerId, request }, runs that provider on the server — the
request, the key, the vendor call, the stream — and streams the reply back as NDJSON, one event
per line; a request with stream: false gets a { text } body. On those runtimes @aparte/core
resolves to its node entry, which has no DOM
(On the server), and so does it in a Cloudflare Worker or on
a Vercel edge route.
| Option | What it does |
|---|---|
providers | the providers, keyed by the providerId the page sends: an unknown id is a 400, a provider that is not a format adapter a 500 |
authorize(req) | required, run before anything else: false answers 401, a Response answers itself, true goes on |
resolveKey(providerId) | the vendor key, from your environment or a secret store; undefined for a keyless server |
fetchImpl | the fetch that calls the vendor |
These are the AparteChatHandlerOptions.
What the route accepts
Section titled “What the route accepts”The page sends a providerId, never a URL: the vendor’s address comes from the provider in your
map, so no request can point your server at another host. A vendor’s error keeps its status, and
its body reaches the page as { error: { message, code?, type? } }, without the vendor’s own text.
The rest of the request is the page’s: modelId, the messages, the system prompt, the tools and
maxTokens reach the provider as sent, so authorize decides who calls the route, not what they
send. To limit the models or the size, check the body in a wrapper of yours, or in authorize
through req.clone().json(): clone it, because the handler reads the body itself.
Point the page at it
Section titled “Point the page at it”import { aparteGlobalConfig, AparteClient, AparteBackendTransport } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OPENAI)); // the id the route knows, no key
aparteGlobalConfig.setTransport(new AparteBackendTransport({ endpoint: '/api/chat' }));
aparteGlobalConfig.setModelConfig({ defaultProvider: 'openai', defaultModel: 'gpt-4.1-mini' });
new AparteClient().start();The page posts { providerId, request } to your route and streams the reply into the chat; no key
is in it. Register a provider under each id the route knows: the page reads what a model can do
from it, and refuses to send to an id it does not have. BackendTransportOptions takes the
endpoint, headers sent with every request, and buildBody(request, providerId) for a route
that expects another body. A cookie goes along only to a route on the page’s own origin, so a
route on another origin is authenticated through headers.
Nothing of that provider runs in the page: no request is built from it and its chat() is never
called. For an id no client package exists for, a stub that lists the models is enough:
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerAIProvider({
id: 'internal',
getMetadata: () => ({ id: 'internal', name: 'Internal' }),
getModels: () => [{ id: 'house-model', name: 'House model', capabilities: ['streaming', 'function_calling'] }],
chat: () => { throw new Error('runs on the server'); },
});The tools and the loop stay in the page: the route makes one model call at a time. When the tools live on your server, move the loop there too, with the server loop.
If your server already uses the AI SDK
Section titled “If your server already uses the AI SDK”npm install @aparte/core @aparte/provider-ai-sdk ai @ai-sdk/anthropic// app/api/chat/route.ts (Next.js) — the AI SDK on your server, aparté in the page
import { createAnthropic } from '@ai-sdk/anthropic';
import { streamText } from 'ai';
import type { AparteChatRequest, AparteStreamEvent } from '@aparte/core';
import { fullStreamToAparteEvents, toModelMessages, toToolChoice, toToolSet } from '@aparte/provider-ai-sdk';
declare function getSession(req: Request): Promise<{ userId: string } | null>; // your session lookup
const MODELS = ['claude-sonnet-4-5', 'claude-haiku-4-5'];
export async function POST(req: Request): Promise<Response> {
if (!(await getSession(req))) return new Response('Unauthorized', { status: 401 });
const { request } = (await req.json()) as { request: AparteChatRequest };
if (!MODELS.includes(request.modelId)) return new Response('Unknown model', { status: 400 });
const result = streamText({
model: createAnthropic({ apiKey: process.env['ANTHROPIC_API_KEY'] })(request.modelId),
messages: toModelMessages(request.messages),
temperature: request.temperature,
maxOutputTokens: request.maxTokens,
seed: request.seed,
abortSignal: req.signal,
...(request.tools?.length
? { tools: toToolSet(request.tools), toolChoice: toToolChoice(request.toolChoice) ?? 'auto' }
: {}),
});
if (request.stream === false) {
try {
return Response.json({ text: await result.text });
} catch (err) {
console.error('[api/chat] vendor request failed', err);
return Response.json({ error: { message: 'Vendor request failed.' } }, { status: 502 });
}
}
const ndjson = fullStreamToAparteEvents(result.fullStream)
.pipeThrough(new TransformStream<AparteStreamEvent, AparteStreamEvent>({
transform: (event, controller) => {
if (event.type === 'error') {
console.error('[api/chat] vendor stream error', event.message); // the vendor's words stay in your log
controller.enqueue({ type: 'error', message: 'Vendor request failed.' });
return;
}
controller.enqueue(event);
},
}))
.pipeThrough(new TransformStream<AparteStreamEvent, string>({
transform: (event, controller) => controller.enqueue(`${JSON.stringify(event)}\n`),
}))
.pipeThrough(new TextEncoderStream());
return new Response(ndjson, { headers: { 'content-type': 'application/x-ndjson' } });
}import { aparteGlobalConfig, AparteClient, AparteBackendTransport } from '@aparte/core';
import { createAiSdkProvider } from '@aparte/provider-ai-sdk';
// The id the page sends, and what the model can do; the call itself is the route's.
aparteGlobalConfig.registerAIProvider(createAiSdkProvider({
id: 'anthropic',
name: 'Anthropic',
languageModel: () => { throw new Error('This provider runs on the server.'); },
}));
aparteGlobalConfig.setTransport(new AparteBackendTransport({ endpoint: '/api/chat' }));
aparteGlobalConfig.setModelConfig({ defaultProvider: 'anthropic', defaultModel: 'claude-sonnet-4-5' });
new AparteClient().start();The handler above runs format adapters, and the AI SDK bridge is not
one: it makes its own call through streamText. Its four helpers make the same route by hand —
toModelMessages and toToolChoice translate the request, toToolSet gives the SDK the tools
as declarations, which the page’s loop runs, and fullStreamToAparteEvents turns the SDK’s
stream into the events the page reads. The page registers a provider under the id it sends and
names one of the route’s models; its languageModel is never called, since the route makes the
call. Two things in this route are what the handler does for you: the
vendor’s error replaced before it leaves the server, and the { text } answer to a request with
stream: false. The check on the model is one the handler does not make; add it to either route.
Write the stream yourself
Section titled “Write the stream yourself”{"type":"text","delta":"Hel"}
{"type":"text","delta":"lo."}
{"type":"done","usage":{"inputTokens":12,"outputTokens":3,"totalTokens":15}}A route of your own answers AparteBackendTransport with NDJSON, one AparteStreamEvent per
line — aparté’s events, not the AI SDK’s data stream protocol. parseAparteEventStream(body)
reads that stream back into events. A transport of your own is set with
aparteGlobalConfig.setTransport, like the two built in
(AparteTransport).