fusion-ai-host
fusion-ai-host is the stack's hands on the
user's machine. Where fusion-ai is the brain — the seam that runs the model and the agent
loop — fusion-ai-host is the set of tools that execute where the user's files and local toolchains
live: list a folder, search a repo, rename a file, convert an IFC. It is not AI and not a UI; it is a
library of tool implementations plus a thin device-side runtime — enrollment and dispatch — and it
opens no inbound port on the machine.
Coming from the rest of Fusion? Same shape as
fusion-ai: the package owns the mechanism, the app owns the composition.fusion-aire-exportstoolDefinitionand runsstreamChat;fusion-ai-hostre-exports a local-tool contract (defineLocalTool) and runs the device-side dispatch loop. The app wires both and owns the device-registry table.
The two halves
The package splits across two entry points, mirroring fusion-ai and fusion-ai/agent.
@tikab-interactive/fusion-ai-hostruns on the user's machine: the local-tool contract, the built-in tools, the enrollment client, and the dispatch loop that receives tool calls and runs them. It compiles to a single self-contained binary withbun build --compile— no Bun/Node install on the target, which suits internal and air-gapped delivery.@tikab-interactive/fusion-ai-host/serverruns on the VM alongside PulsN: the in-memory dispatcher that routes a call to a connected device's stream and awaits its result. It is schema-agnostic — the app owns the device-registry table and its migration, the same rule asfusion-db.
A headless CLI, on purpose
fusion-ai-host is, and stays, a headless command-line connector. There is no desktop or GUI app, no
Tauri port, and no tray / menu-bar wrapper planned — Carola in the browser is the
interface, and the CLI is headless muscle that dials out and answers dispatches.
The form factor follows from who the feature is for, which is not who the product is for. PulsN serves
a construction-industry majority with low IT maturity — engineers, architects and consultants, some
still working partly on paper. The connector serves only the power users at the edges of that group —
surveyors, scan-to-BIM specialists, heavy Revit / AutoCAD users — because only they have the problem it
solves: working in place on local files (rename-by-schema, E57 → LAS → merge, export pipelines). The
two audiences barely overlap.
Two scenarios pull in different directions, and only one of them needs a local presence. Dragging an IFC into the browser to be crunched on the server is a plain web upload — it never touches the connector, and the upload / download round-trip is inherent to server-side processing, not a rough edge to design away. Working in place — point at a folder, rename or convert the files where they sit — is the only thing that genuinely requires something running on the machine, and it is power-user-only. The form factor is decided on that in-place case.
"Make it a desktop app like Cowork" is a category error. Cowork is a desktop app because it has no server — the app is the runtime. Fusion is the inverse: the runtime is the server-side agent loop, and the CLI is only a hand it reaches with. Cloning Cowork's shell would copy the form without the reason for it. Borrow its ergonomics — in-place file work, native consent — where they are cheap; don't clone the wrapper.
Power users are also the audience least bothered by a terminal. They are terminal-adjacent and tend to
prefer a scriptable, re-runnable, flag-driven command — fusion-host convert e57 --to las --merge ./scans — to a window to click through. The heavy domain pipelines those commands drive are the
named-shell-out tier (see the tool surface), not the built-in filesystem tools, and
a CLI is arguably the better surface for them, not a worse one.
A tray wrapper is therefore demoted, not planned: it is worth building only if a concrete need for in-place local file work from a non-power-user actually appears — which, for this audience, may never happen. The one ergonomic debt worth paying regardless of form factor is the enrollment moment — the click-the-link device flow (see transport). It has to be genuinely smooth: a clear link, an unmistakable "you're connected" confirmation, and good errors when the internal CA isn't trusted — because it is the single point of friction even a power user meets.
The connector's own docs carry this decision from the package side — see a headless CLI, on purpose.
Relationship to MCP
In plain terms — for a project lead or client, not a developer. MCP, the Model Context Protocol, is a shared standard for describing a tool to an AI assistant. Think of it as a universal adapter: the way a single USB-C cable fits many devices, it is a standard "plug" that lets any AI assistant connect to many different tools. Our connector follows that standard's shape — it describes each of its tools in the same agreed-on way — so the tools stay portable. We keep the standard's shape; we simply don't use its usual way of connecting.
That last part is the difference, and it is deliberate. The standard's usual connections either run a tool on the same computer as the AI, or turn the user's machine into something the AI reaches into — an open door. Ours works the other way around: the connector on the laptop dials out to the server, like a phone calling the office rather than the office reaching into your laptop. The payoff is that nothing on the user's machine is left open to attack, it works wherever the laptop happens to be, and it suits secure or internal deployments. The technical version is the rest of this section, plus the tool surface and transport.
In MCP's terms, fusion-ai-host adopts the tool-description contract and not the transport.
defineLocalTool declares a tool as the same triple MCP uses — a name, a description and a typed input —
so a tool is portable and the model meets a familiar, declarative surface (the tool
surface). What it does not borrow is MCP's connection methods: rather than a
co-located tool server, or a local endpoint the model reaches into, the connector is outbound-only — it
dials the server and answers dispatches (transport). Same contract,
different connection — which is exactly what keeps the machine closed while keeping the tools portable.
Transport — outbound, never inbound
The CLI dials out to PulsN and registers as the local executor for the user's session; the server pushes a call down that outbound connection and the CLI answers back. Nothing listens on the machine — there is no inbound port to probe and no firewall exception to request.
Enrollment is a one-time pairing code minted by a signed-in user; the device exchanges it for a
per-device bearer token (stored hashed server-side, bound to a server-issued device_id). Each
(user, device) pair is its own credential, so a lost laptop is revoked individually.
v1 transport. The shipped version carries calls over Server-Sent Events (
GET /api/host/stream) with results POSTed back (POST /api/host/result) — outbound-only, zero extra infra, and it rides the same path the chat UI already streams over. The end-state moves this behind Centrifugo oncefusion-realtimegrows per-device subscription tokens; the auth end-state is Better Auth's device-authorization grant.
The tool surface
Built-in tools are classified read, mutate, or action — which decides whether they run on
arrival or go through propose/apply. Domain tools (e.g. convert_ifc) are declared by the consumer.
| Tool | Class | What it does |
|---|---|---|
list_directory | read | list a folder (optionally recursive) |
read_file | read | read a UTF-8 file (line range; binary → metadata) |
stat | read | size / type / timestamps |
glob | read | find files by glob pattern |
grep | read | search file contents (regex / literal, context) |
disk_usage | read | recursive size + file count of a tree |
hash_file | read | sha256 / md5 of a file |
write_file | mutate | create / overwrite (diff proposal) |
edit_file | mutate | exact-match find/replace edits (diff proposal) |
append_file | mutate | append text |
create_directory | mutate | mkdir -p |
move | mutate | move or rename |
copy | mutate | copy a file or tree |
delete | mutate | delete a file, or a tree with recursive |
open_path | action | open with the OS default app |
reveal_in_os | action | reveal in Finder / Explorer |
Every read is bounded and reports truncation (directory listings cap at 1000 entries, grep at
200 matches / 1 KB per line, read_file at 1 MB), so a tool result can never blow the model's context
or the channel. The read tools are wired into Carola, so "search my repo for X" or
"read ~/notes/today.md on my Mac" work in natural language, owner-scoped to the signed-in user.
Propose / apply — consent across three processes
Reads and actions run on arrival. A mutating tool is two-phase, because the approver (the browser), the loop (the VM) and the actor (the headless CLI) are three different processes — consent is a separate dispatched step, not a blocking prompt on a machine with no one sitting at it.
The tool's plan() does a dry-run and returns the exact diff without touching disk; on approval
the server re-dispatches the server-stored input in apply mode — the client can't swap it between
propose and approve, and edit_file re-verifies its find text (the TOCTOU guard). Approval lives in
the example's /devices page (Approve / Reject) and at POST /api/host/approve.
Security model
Two layers of trust, and one deliberate omission:
- Scope is a set of canonicalized roots. Every path is resolved to its absolute, symlink-expanded
real path first, then checked to fall inside an allowed root — defeating
..traversal, symlink/junction escapes, and (on Windows) UNC /\\?\prefixes. AstartsWith(root)check on a raw path does not. The model is told the roots so it proposes valid paths, but it cannot widen them. - No raw
exec. A general shell can't be path-canonicalized, can't run through the consent gate, and can't be reasoned about by a reviewer — on a classified endpoint it is a remote shell on every machine. The package ships named shell-outs instead (the command is fixed, only the scoped arguments vary —convert_ifc,open_path), keeping the power where it's needed without handing the model a shell. - OS trust is the operator's job. A freshly compiled, unsigned binary is the textbook untrusted app (macOS Gatekeeper, Windows SmartScreen / AppLocker). Signing + fleet policy is deployment work, not code — and the tool layer, not the OS, is the fine-grained scoper.
File content fed back to the model is fenced the same way the proactive agent fences its signal content — a file can carry "ignore previous instructions".
In the example
fusion-meta is the reference consumer. The web app (example/) composes …/server — the
device-registry table, the dispatcher, and a /devices control surface (run any tool, approve
mutations). The device binary's source is a second buildable artifact, example-host/:
import { builtinTools, runHost } from "@tikab-interactive/fusion-ai-host";
import { convertIfc } from "./tools/convert-ifc";
await runHost({
server: process.env.PULSN_URL ?? "https://pulsn.internal",
tools: [...builtinTools, convertIfc],
});cd example-host && bun run compile:mac # one self-contained, ad-hoc-signed binary
PULSN_URL=https://… FUSION_HOST_CODE=… ./fusion-hostThe package is exercised by a bun test suite — path canonicalization, the diff helper, every read +
mutate tool's propose/apply, and the dispatcher.
Status
Shipped and deployed end-to-end (a cloud-dispatched list_directory, and a full write_file
propose → approve → apply verified against a binary on a real laptop). Deliberate v1 deviations: the
HTTP/SSE transport (not Centrifugo yet), pairing-code auth (not the full device-authorization flow
yet), the in-memory dispatcher (so the deployment runs single-replica), and read_file truncating
large files (the object-storage handle is the next slice).