Keep the key server-side — chat through your own /api/chat
Every aparté chat goes through a transport: where the request goes and how the
key is handled. AparteDirectTransport calls the
vendor straight from the browser — fine for BYOK or a local model, but it puts the key in
devtools. AparteBackendTransport instead POSTs to your own endpoint; your server resolves
the vendor key, calls the vendor, and streams normalized events back. The key never
reaches the browser.
When to use it
Section titled “When to use it”AparteDirectTransport | AparteBackendTransport | |
|---|---|---|
| Key location | Browser (devtools-visible) | Server only |
| Good for | BYOK, local models (Ollama, LM Studio), prototyping | Production / SaaS with a key you pay for |
| Client needs | The vendor’s format adapter | Only a providerId string |
If your app pays for the API key, use AparteBackendTransport. If the user supplies their own
key (or the model runs locally, keyless), AparteDirectTransport is simpler and there’s no
server hop.
Writing your own transport
Section titled “Writing your own transport”AparteAIProvider is a union of two arms: a provider either shapes payloads (a format
adapter — buildRequest, parseStream, an endpoint and a way to present a key) or owns its
I/O in a chat() method, the way @aparte/provider-transformers runs a model locally. The
compiler tells an author which arm they implemented; isFormatAdapter tells a transport
which arm it was handed, and narrows the type as it answers:
import { isFormatAdapter, aparteGlobalConfig, type AparteChatRequest } from '@aparte/core';
async function dispatch(providerId: string, request: AparteChatRequest) { const provider = aparteGlobalConfig.getAIProvider(providerId); if (!provider) throw new Error(`no provider registered for "${providerId}"`);
if (isFormatAdapter(provider)) { // Narrowed: buildRequest / parseStream / defaultEndpoint are all non-optional here, // so you do the HTTP and the auth, and the provider only shapes the bytes. const { path, body } = provider.buildRequest(request); return { url: provider.defaultEndpoint + path, body }; }
// The other arm: the provider does its own I/O, so stay out of the way. return provider.chat?.(request);}void dispatch;Both built-in transports do exactly this — it is why one map of providers serves a browser-direct app and a server-held-key app without either provider knowing which it is in.
1. Build the server handler
Section titled “1. Build the server handler”createAparteChatHandler builds a framework-free /api/chat handler: a plain
(req: Request) => Promise<Response> using only the Web fetch API, so it drops into a
Next.js route handler, Deno, Bun, or a Cloudflare Worker unchanged. It reads
{ providerId, request }, runs the matching format adapter server-side
(buildRequest → auth → vendor fetch → parseStream), and re-emits the result as NDJSON
(one JSON object per line) — the exact wire format AparteBackendTransport expects on the way
back.
Importing @aparte/core on the server is fine: a node export condition resolves to a
DOM-free entry (no custom elements, no CSS), and the same holds for the format-adapter
providers. See On the server for what the
DOM-free entry keeps and loses, and for what each wrapper does about it — the contract is
enforced in CI by a real Node import, not just documented.
// app/api/chat/route.ts (Next.js) — runs in the Node.js runtimeimport { createAparteChatHandler } from '@aparte/core';import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
// Your own session lookup — the same one guarding your other authenticated routes.declare function getSession(req: Request): Promise<{ userId: string } | null>;
export const POST = createAparteChatHandler({ providers: { openai: createOpenAICompatProvider(presets.OPENAI), }, resolveKey: (providerId) => process.env[`${providerId.toUpperCase()}_KEY`], // REQUIRED. This route spends your key, so your own auth goes here. Return false // for 401, a Response for your own status, or true to proceed. authorize: async (req) => Boolean(await getSession(req)),});createAparteChatHandler and its AparteChatHandlerOptions type are exported from
@aparte/core’s Node/SSR entry (resolved automatically via the node export
condition when a server file does import '@aparte/core') — that entry is DOM-free, so
importing it on the server never touches HTMLElement.
Handler options:
providers— aRecord<string, AparteAIProvider>keyed by theproviderIdthe client will send (the same@aparte/provider-*adapters you’d use withAparteDirectTransport— nothing changes about the adapter itself). Each entry must expose the format-adapter surface (buildRequest+parseStream+defaultEndpoint, plusauthHeadersorauthQuery) —createOpenAICompatProvider(...)already does. An unregisteredproviderIdgets a400; a provider missing the adapter surface gets a500.resolveKey(providerId)— pulls the vendor key from env/a secret store, server-side only. Returnundefinedfor keyless/local providers.fetchImpl— override thefetchused to call the vendor (defaults to globalfetch), e.g. in tests or behind a proxy.authorize(req)— an auth gate run on every request before any work. Returnfalseto reject with401, aResponseto reject with your own status/body (e.g.403+ a message), ortrueto proceed. Read cookies/headers fromreq.
Register one entry per vendor you support; the map key is what the client sends as
providerId, so route between OpenAI, Mistral, OpenRouter, etc. by adding more entries.
SSRF safety
Section titled “SSRF safety”The client never sends a URL — only a providerId string. The vendor URL comes from
adapter.defaultEndpoint inside your providers map, resolved on the server; nothing
in the request body can redirect the server to an arbitrary host. A malicious or buggy
client can pick a registered provider at most, never an arbitrary endpoint. Vendor
errors (bad key, rate limit, etc.) keep their original status, but their body is
summarised to { error: { message, code?, type? } } rather than relayed. That is
deliberate: an OpenAI 401 body reads Incorrect API key provided: sk-proj-****abcd, so
passing it through would hand a caller your key’s prefix, tail and format — and other
vendors echo organisation ids and request fragments. The machine-readable code / type
survive, which is what a client actually branches on; the vendor’s prose belongs in your
server’s logs. (AparteDirectTransport has no such concern: there, the key is the
caller’s own.)
The rest of the body is not constrained. modelId, the whole messages array (the
system message included), tools and maxTokens come from the client and are forwarded
to the adapter as-is — no size limit, no message count, no model allow-list. An authorized
caller can therefore name any provider in your map and send a prompt of any length against
your key: authorize decides who calls the route, never what they send. If your
deployment needs that capped — per-tier models, a token budget — do it in your own wrapper
around the handler, or inside authorize reading req.clone().json(). Clone it: the
handler parses the original body itself, and a body read twice fails the request with a
400.
2. Point the browser at it
Section titled “2. Point the browser at it”On the client, skip the provider adapter entirely — the browser only needs to know the
providerId and where your endpoint lives. Set AparteBackendTransport instead of
AparteDirectTransport and drive the rest exactly as usual:
import { aparteGlobalConfig, AparteClient, AparteBackendTransport } from '@aparte/core';
aparteGlobalConfig.setTransport(new AparteBackendTransport({ endpoint: '/api/chat' }));new AparteClient().start(); // .start() attaches the aparte-send/-retry/-edit listenersNo key, no adapter import, nothing devtools-visible — the browser just POSTs
{ providerId, request } to /api/chat and streams the reply back into your bubbles.
BackendTransportOptions:
endpoint— your chat route, e.g./api/chat.headers— extra headers sent with every request. A session cookie is sent automatically only whenendpointis same-origin (e.g./api/chat); a cross-origin endpoint sends none, so authenticate it with a header here.buildBody— override how the request is serialized to your backend. Defaults to{ providerId, request }; return any JSON-serializable value if your route expects a different shape.
Wire format
Section titled “Wire format”The NDJSON AparteBackendTransport reads back is aparté’s own — one JSON AparteStreamEvent
per line — not the Vercel AI SDK Data Stream Protocol. You don’t need to think about
this if you use createAparteChatHandler on the server (it produces exactly this format),
but a hand-rolled route must match it if you skip the helper.
Next steps
Section titled “Next steps”- Providers — the format adapters you register in the
providersmap (OpenAI-compatible, the AI SDK bridge, Transformers.js). - Getting started — the
AparteDirectTransport/ BYOK path, for contrast. - The agent engine —
runStreamAgent, for a headless loop instead of theAparteClientevent wiring shown here.