Hosting it from another app
Introduction
Section titled “Introduction”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.
Running it in its own process
Section titled “Running it in its own process”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.
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 wentprocess.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.
Handing the address to the page
Section titled “Handing the address to the page”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:
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 credentialsThe token goes to chat-core as credentials: () => token, so REST and the socket handshake both carry it. See Connecting a client.
Owning the config folder
Section titled “Owning the config folder”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>/templatesat 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 perlocal.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.envfrom the app data directory and passes the merged result asenv.
Client tools need nothing on the host
Section titled “Client tools need nothing on the host”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.
Borrowing models from a shared server
Section titled “Borrowing models from a shared server”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.
Consuming the tarballs
Section titled “Consuming the tarballs”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.
Bundling Node and better-sqlite3
Section titled “Bundling Node and better-sqlite3”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]inpnpm-workspace.yaml, thenpnpm 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 exampleprebuild-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.
Next steps
Section titled “Next steps”- Local backend for every option of
startLocalServer. - Security for origins and the token.
- Storage and restarts for what lives in
dataDir.