Run the agent loop on your server
Run the agent loop next to your tools — a handler on your server, a runner in the page — with approvals that cross the network and survive a reload, and browser tools that still run in the page.
Your tools live on your server — files, a database, secrets — and the model has to call them. With the loop on the server, next to them, there is nothing to expose, and the page keeps its tool rows, Stop, the approval panel and your approval policy. To keep only the key off the browser, the backend transport is enough; to drive the chat from a loop of your own, see Your own loop.
The server
Section titled “The server”// app/api/agent/route.ts (Next.js)
import { createAparteAgentHandler } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
export const POST = createAparteAgentHandler({
providers: { openai: createOpenAICompatProvider(presets.OPENAI) },
defaultModel: 'gpt-4.1-mini', // when the page selects none
resolveKey: () => process.env.OPENAI_API_KEY,
authorize: (req) => isSignedIn(req), // required: this route spends your key
secret: process.env.AGENT_SECRET!, // required, 32+ characters
tools: [{
tool: {
name: 'read_file',
description: 'Read a file from the site content.',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
},
handler: readFileHandler,
}],
});createAparteAgentHandler returns a Fetch handler, Request to Response, for a Next.js route in
the Node runtime, Deno, Bun, or Node behind a small adapter — runtimes that resolve @aparte/core
to its entry without a DOM (On the server). authorize returns false, for a 401, or a
Response of its own. secret signs the calls a paused run leaves pending; an array rotates it,
the first signing and all verifying.
| Option | What it does |
|---|---|
approvalPolicy | rules on each call, as setApprovalPolicy does in the page |
clientTools | whether the page’s own tools are accepted: true (the default), false, or a list of names |
prepareRequest(request, req) | rewrites the request before the run, on every request a resume included: a model, a temperature, a system prompt |
maxTurns, toolTimeoutMs | the model’s turns in one run, ten by default — a resume after a pause counts again — and a tool’s time, five minutes by default; a tool’s own maxTurns applies too |
ticketMaxAgeMs | how long a paused run can wait, seven days by default |
onTicket(id) | a check on each resume; false refuses it with a 409 |
transport | how the server calls the model, new AparteDirectTransport({ byok: true }) by default |
crypto | the Web Crypto used to sign, for Node 18 |
On Node’s own http server, hand it a Request and stream the Response back. Stop in the
browser closes the connection, and the handler stops the run — the model call and any tool still
running — when its request is aborted:
import { createServer } from 'node:http';
// handler = createAparteAgentHandler({ … }), as above
createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk);
const closed = new AbortController();
res.on('close', () => closed.abort());
const response = await handler(new Request(`http://localhost${req.url}`, {
method: req.method, headers: req.headers as HeadersInit, body: Buffer.concat(chunks),
signal: closed.signal,
}));
res.writeHead(response.status, Object.fromEntries(response.headers));
if (response.body) for await (const chunk of response.body) res.write(chunk);
res.end();
}).listen(3000);The browser
Section titled “The browser”import { AparteClient, createAgentStreamRunner } from '@aparte/core';
const client = new AparteClient({
streamRunner: createAgentStreamRunner({
endpoint: '/api/agent',
headers: async () => ({ authorization: `Bearer ${sessionStorage.getItem('token') ?? ''}` }),
}),
});
client.start();The runner sends each turn to your endpoint and replays the server’s events, which the page draws
as it draws a loop run in the page. A function for headers is called for each request, which is
where a fresh token goes, and fetch replaces the one it calls. credentials: 'include' sends
cookies to another origin; the handler sets no CORS headers, so your route or proxy answers the
preflight (OPTIONS) and adds Access-Control-Allow-Origin (the page’s origin) and
Access-Control-Allow-Credentials: true.
The page registers no provider: the server runs its only provider, on defaultModel. When the
handler has several, the page names one by its key in providers:
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setModelConfig({ defaultProvider: 'openai', defaultModel: 'gpt-4.1' });A model the page selects with setModelConfig wins over defaultModel; prepareRequest runs
after it.
Approvals across the network
Section titled “Approvals across the network”A server tool with needsApproval: true, or a call the server’s approvalPolicy answers ask,
suspends the run: the stream ends with the pending call and a signed ticket, the page asks with
the approval panel, and the answer resumes the
run on the server, in the same reply. The server decides which calls need a person; the page
answers, through the person or through its own approval policy, such as
@aparte/plugin-approval’s modes. The server can also refuse
outright, with no one asked:
createAparteAgentHandler({
// …
approvalPolicy: (call) => (call.name === 'delete_file' ? { verdict: 'deny', reason: 'Not from the website.' } : undefined),
});The person can leave and come back: nothing is stored on the server, the paused call lives in the
conversation you already save. A conversation manager saves
the reply when the run pauses — the chat sends aparte-message-paused — and when it opens a
conversation whose last reply is paused, the panel comes back. Saving another way, listen for
aparte-message-paused on the chat — it bubbles — and save the message as it is, its segments
included, since the paused call and its ticket are in them; after setMessages, call
await client.resumeDeferred(). A new message sent instead of an answer closes the paused call.
Browser tools
Section titled “Browser tools”A tool that needs the page stays registered in the page: setupAskUser() registers
ask_user, and registerTool your own. The runner sends its definition
along; the server lets the model call it, pauses on it, and the page runs its handler and sends
the result back. The page runs it as soon as the model calls it: its needsApproval and the page’s
approval policy are not consulted on this path, so a browser tool that needs a person asks in its
own handler, as ask_user does. To refuse browser tools, or accept a list of them:
createAparteAgentHandler({
// …
clientTools: ['ask_user'], // or false
});A browser tool may not share a server tool’s name: the request is refused.
Open a file in the page when a server tool runs
Section titled “Open a file in the page when a server tool runs”To have the page do something when a call runs — open a file in the editor beside the chat —
without a pause, make the tool a server tool that answers by itself, with what the page needs in
its structuredContent, and act in the page when its call resolves:
createAparteAgentHandler({
// …
tools: [{
tool: {
name: 'open_file',
description: 'Open a file in the editor beside the chat.',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
},
handler: async (call) => ({ toolCallId: call.id, content: 'opened', structuredContent: { openFile: String(call.input.path) } }),
}],
});import { AparteClient, createAgentStreamRunner } from '@aparte/core';
import type { AparteStreamRunner } from '@aparte/core';
declare function openFile(path: string): void; // your page's own code
const runner = createAgentStreamRunner({ endpoint: '/api/agent' });
const openingFiles: AparteStreamRunner = (opts) => runner({
...opts,
emitter: (event) => {
if (event.type === 'tool-resolved') {
const result = event.structuredResult as { openFile?: string } | undefined;
if (result?.openFile) openFile(result.openFile);
}
opts.emitter(event);
},
});
new AparteClient({ streamRunner: openingFiles }).start();tool-resolved arrives once the server tool has succeeded, a call resumed after a pause included,
so a failed call opens nothing. A conversation opened later draws its finished calls from its
history and runs none of them, so the file is not opened again. The same wrapping works on any
runner.
What the ticket protects, and what it does not
Section titled “What the ticket protects, and what it does not”The ticket is a signature over the pending calls: a resume runs only calls this server emitted, for the reply it paused. Three things it does not do:
- It does not make a tool’s input safe. The model chose the arguments; a tool checks the
caller’s rights itself. Its handler gets the request being served as
context.request— for a call answered after a pause, the request that resumed the run. - It does not prove a person approved. The browser sends the decision, so a caller holding the
ticket can approve any call it names:
needsApprovalasks a person, and the handler still checks the caller’s rights. - It is not single-use. A server that stores nothing cannot tell a replay from a first use;
if yours can,
onTicketrefuses a ticket it has seen.
createAparteAgentHandler({
// …
onTicket: async (id) => {
if (await seen.has(id)) return false; // replayed: refused with a 409
await seen.add(id);
return true;
},
tools: [{
tool: {
name: 'delete_file',
description: 'Delete a file.',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
needsApproval: true,
},
handler: async (call, signal, context) => {
const user = await userOf(context?.request);
const path = String(call.input.path);
if (!user?.canWrite(path)) return { toolCallId: call.id, content: 'You cannot delete this file.' };
await removeFile(path, { signal });
return { toolCallId: call.id, content: `Deleted ${path}.` };
},
}],
});An approval’s payload — data an approval surface merges into the arguments — is dropped unless
the tool opts in with acceptsApprovalPayload: true.
Node 18
Section titled “Node 18”The ticket is signed with Web Crypto, which browsers, Node 19 and later, Deno, Bun and Workers have. On Node 18, pass it in:
import { webcrypto } from 'node:crypto';
createAparteAgentHandler({
// …
crypto: webcrypto.subtle as SubtleCrypto,
});The wire
Section titled “The wire”NDJSON, one event per line: the engine’s run events as they are, a
run-suspended line carrying the ticket, and a run-error line, never the vendor’s own words,
when the run fails after the stream began. A server tool that throws ends the run: the page shows
the call as failed — The tool failed. — then the reply fails, and the error itself goes to your
server’s log; to let the model read a failure and go on, return it as content. An error before
the stream is JSON with an HTTP status. The body is { v: 1, providerId?, messageId, request, clientTools, resume? }, which the runner builds. Among the 400s, two concern tools: a
toolChoice that names a tool, and a history ending on unanswered calls with no resume ticket.