Skip to content
Pilotoo
v0.1

Desktop app with a local backend

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.

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

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.

sidecar/launcher.mjs
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:

sidecar/host-tools.mjs
import { createAskHumanTool, createWebSearchTool } from '@pilotoo/agent-runtime';
export function hostTools(env) {
return (registry) => {
registry.register(createWebSearchTool(env));
registry.register(createAskHumanTool(), { optIn: true });
};
}

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.

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.

src/features/chat/backend-handoff.ts
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:

src/main.tsx
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.

  • The backend binds to 127.0.0.1 only, and every REST call and socket handshake must carry Authorization: Bearer <token>. A request with an Origin outside allowedOrigins is 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 uses VITE_API_URL.