Skip to content

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.

On this 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.

ts
// 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.

OptionWhat it does
approvalPolicyrules on each call, as setApprovalPolicy does in the page
clientToolswhether 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, toolTimeoutMsthe 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
ticketMaxAgeMshow long a paused run can wait, seven days by default
onTicket(id)a check on each resume; false refuses it with a 409
transporthow the server calls the model, new AparteDirectTransport({ byok: true }) by default
cryptothe 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:

ts
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);
ts
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:

ts
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.

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:

ts
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.

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:

ts
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:

ts
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) } }),
  }],
});
ts
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: needsApproval asks 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, onTicket refuses a ticket it has seen.
ts
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.

The ticket is signed with Web Crypto, which browsers, Node 19 and later, Deno, Bun and Workers have. On Node 18, pass it in:

ts
import { webcrypto } from 'node:crypto';

createAparteAgentHandler({
  // …
  crypto: webcrypto.subtle as SubtleCrypto,
});

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.