Run the agent loop on your server: createAparteAgentHandler on the server and createAgentStreamRunner in the page: tools run next to your server, approvals cross the network and survive a reload, browser tools still run in the page. # 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](/guides/backend-transport/) is enough; to drive the chat from a loop of your own, see [Your own loop](/guides/bring-your-own-loop/). ## The server [Section titled “The server”](#the-server) ```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](/frameworks/elements/#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](#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); ``` ## The browser [Section titled “The browser”](#the-browser) ```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. ## Approvals across the network [Section titled “Approvals across the network”](#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](/guides/tools/#require-approval-human-in-the-loop), 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`](/components/approval-mode/)’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](/guides/conversation-persistence/) 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”](#browser-tools) A tool that needs the page stays registered in the page: `setupAskUser()` registers [`ask_user`](/components/question/), 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”](#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”](#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`. ## Node 18 [Section titled “Node 18”](#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: ```ts import { webcrypto } from 'node:crypto'; createAparteAgentHandler({ // … crypto: webcrypto.subtle as SubtleCrypto, }); ``` ## The wire [Section titled “The wire”](#the-wire) NDJSON, one event per line: the [engine’s run events](/guides/engine/) 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.