Skip to content
Pilotoo
v0.1

Hosting it from another app

Nothing in @pilotoo/local-server knows its host, so any desktop app hosts it the same way: run it in a child Node process, read one line of JSON with its address and token, and give that to the page. The repository’s desktop app (apps/desktop) is the reference host.

Run startLocalServer in a child process, not inside an Electron or Tauri main process. The desktop sidecar is a small Node script that the shell starts with the Node it bundles.

apps/desktop/sidecar/launcher.mjs
const server = await startLocalServer({
configDir, dataDir, workspaceDir,
execution: 'local-unsandboxed',
defaultAgentId: 'orchestrator',
allowedOrigins: ['tauri://localhost'],
env,
host: { tools: hostTools(env) },
printTo: process.stdout,
});
process.stdin.on('end', shutdown); // the shell is gone, however it went
process.on('SIGTERM', shutdown);
process.stdin.resume();

Close the child’s stdin or send it SIGTERM to make it close() and exit. Closing stdin is the better choice, because a crashed host then leaves no orphan process. Keep everything except the address line off stdout: the launcher writes its own logs to stderr.

With printTo set, the server writes one line once it listens:

{"url":"http://127.0.0.1:51234","token":"..."}

The port is ephemeral and the token is random per launch, so neither can be a build-time constant. The shell reads the line and sets window.__PILOTOO_BACKEND__ = { url, token } before any page script runs. In the Tauri shell this is an initialization script, which runs on every load, so a menu reload keeps it.

The page resolves it once at startup. resolveBackend in the demo web app returns the hand-off when there is one and the build-time address otherwise:

apps/web/src/features/chat/backend-handoff.ts
const handoff = window.__PILOTOO_BACKEND__;
// { url, token } -> credentials: () => token
// { error } -> thrown as "The local backend did not start: ..."
// undefined -> the build-time apiUrl and no credentials

The token goes to chat-core as credentials: () => token, so REST and the socket handshake both carry it. See Connecting a client.

The app owns configDir. Seed it with the files it lacks and never overwrite one, so an upgrade adds new files without touching the user’s edits (apps/desktop/sidecar/seed-config.mjs does this per file).

  • The runtime lists <configDir>/templates at boot and fails if it is missing, so create it (empty is fine).
  • Pick a stable per-user location (the reference desktop app uses ~/.pilotoo) and let the user move it.
  • Do not point two processes at one dataDir. Run one process per local.db.
  • Keys come from env. An app opened from a launcher has no shell environment, so read them from a file the app owns. The sidecar reads a .env from the app data directory and passes the merged result as env.

A tool the webview serves is declared by the page on every run. The interrupt and resume travel the protocol, and the page answers with its own code. Tools that run server-side, next to the agent, are registered in code through host.tools. See Client tools.

If your product has a server that holds the provider keys, the local backend can borrow its models instead of holding keys. See Model gateway and pass host.modelGateway.

pnpm release:check packs @pilotoo/local-server with the other packages of the release set (pnpm pack, one .tgz each). A host that vendors them installs local-server, server, agent-runtime and chat-protocol together, since they are one version set, and satisfies the NestJS peers.

The native module needs a binary for the Node ABI and platform it runs on.

  • With pnpm 10 its install script does not run unless allowed: onlyBuiltDependencies: [better-sqlite3] in pnpm-workspace.yaml, then pnpm rebuild better-sqlite3.
  • An app that ships its own pinned Node (Node 22, matching engines) must fetch the prebuilt binary for that Node and the target platform, for example prebuild-install --runtime node --target <node version> --platform <p> --arch <a>, and smoke-test the bundled Node opening a database.

Bundling the pinned Node and the prebuilt better-sqlite3 has been exercised on macOS arm64 (aarch64-apple-darwin) only. Other platforms need the matching prebuilt binary and your own smoke test. Expect a large app: Node alone is about 112 MB, and the macOS arm64 app measured about 290 MB.