Desktop app with a local backend
Introduction
Section titled “Introduction”You will give a desktop window its own backend: a small Node process that runs startLocalServer on SQLite, reports where it listens, and a page that connects to it with a per-launch token. The user installs one app and nothing else.
What you need
Section titled “What you need”- A Tauri 2 shell and a web app that already shows the chat (Add chat to a React app).
- Node available to the app (the demo desktop app bundles it as a sidecar).
- Read Local backend first if you have not.
1. Write the launcher
Section titled “1. Write the launcher”The launcher starts the server and prints one JSON line, {"url":"...","token":"..."}, on stdout. Everything else goes to stderr. The port is chosen by the OS and the token is random per launch.
import { mkdirSync } from 'node:fs';
import { startLocalServer } from '@pilotoo/local-server';
import { hostTools } from './host-tools.mjs';
const appDataDir = process.argv[process.argv.indexOf('--app-data-dir') + 1];
for (const folder of ['config', 'data', 'workspace']) mkdirSync(`${appDataDir}/${folder}`, { recursive: true });
const server = await startLocalServer({ configDir: `${appDataDir}/config`, dataDir: `${appDataDir}/data`, workspaceDir: `${appDataDir}/workspace`, execution: 'local-unsandboxed', defaultAgentId: 'orchestrator', allowedOrigins: ['tauri://localhost', 'http://tauri.localhost', 'https://tauri.localhost'], env: process.env, host: { tools: hostTools(process.env) }, printTo: process.stdout,});
// The shell closes stdin when it exits, however it exits.const shutdown = async () => { await server.close(); process.exit(0);};process.on('SIGTERM', shutdown);process.on('SIGINT', shutdown);process.stdin.on('end', shutdown);process.stdin.resume();The three directories are required and are never searched for; the launcher creates them first. execution has no default on purpose: the agent’s execute tool is a shell on the user’s machine, so you choose it in code.
Register tools exactly as a server does:
import { createAskHumanTool, createWebSearchTool } from '@pilotoo/agent-runtime';
export function hostTools(env) { return (registry) => { registry.register(createWebSearchTool(env)); registry.register(createAskHumanTool(), { optIn: true }); };}2. Hand the address to the page
Section titled “2. Hand the address to the page”The shell starts the launcher, reads its first JSON line, then creates the window with an initialization script. The script runs before any page script, on every load:
window.__PILOTOO_BACKEND__ = { url: "http://127.0.0.1:51234", token: "…" };When the backend fails to start, the script sets { error: "..." } instead.
3. Read it in the page
Section titled “3. Read it in the page”resolveBackend turns the global into a base URL and a credentials function. Without a hand-off (a browser, or a build that talks to a remote server) it uses the address compiled into the app.
import type { CredentialsProvider } from '@pilotoo/chat-core';
export const BACKEND_HANDOFF_KEY = '__PILOTOO_BACKEND__';
export type Backend = { apiUrl: string; credentials: CredentialsProvider | undefined };
const isFilled = (value: unknown): value is string => typeof value === 'string' && value.trim() !== '';
export function resolveBackend(source: object, fallbackApiUrl: () => string): Backend { const handoff = (source as Record<string, unknown>)[BACKEND_HANDOFF_KEY]; if (handoff === undefined) return { apiUrl: fallbackApiUrl(), credentials: undefined };
const record = typeof handoff === 'object' && handoff !== null ? (handoff as Record<string, unknown>) : {}; if (isFilled(record.error)) throw new Error(`The local backend did not start: ${record.error}`); if (!isFilled(record.url) || !isFilled(record.token)) { throw new Error('The local backend hand-off is malformed: it needs a url and a token.'); }
const token = record.token; return { apiUrl: record.url.trim().replace(/\/$/, ''), credentials: () => token };}Use it before you mount, and show an error as plain text:
let backend: Backend | undefined;try { backend = resolveBackend(globalThis, () => import.meta.env.VITE_API_URL);} catch (error) { document.body.textContent = error instanceof Error ? error.message : String(error);}if (backend) mount(backend.apiUrl, backend.credentials);Then pass apiUrl and credentials to ChatProvider (baseUrl, getToken) or createSocketIoTransport.
How it works
Section titled “How it works”- The backend binds to
127.0.0.1only, and every REST call and socket handshake must carryAuthorization: Bearer <token>. A request with anOriginoutsideallowedOriginsis refused even with the token. - The token is at least 16 characters and new on every launch, so another local process or web page cannot use the port.
- A failed start is shown, not hidden: the page never falls back to an address nobody started.
- The demo app can skip the local backend with
PILOTOO_BACKEND=remote, in which case the page usesVITE_API_URL.
Next steps
Section titled “Next steps”- Local backend: security and hosting.
- Borrow server models from a desktop app: no keys on the user’s machine.
- Packages: local-server: every option.