Skip to content
Fusion

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.

Loading diagram...

Coming from the rest of Fusion? Same shape as fusion-ai: the package owns the mechanism, the app owns the composition. fusion-ai re-exports toolDefinition and runs streamChat; fusion-ai-host re-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-host runs 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 with bun build --compile — no Bun/Node install on the target, which suits internal and air-gapped delivery.
  • @tikab-interactive/fusion-ai-host/server runs 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 as fusion-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 once fusion-realtime grows 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.

ToolClassWhat it does
list_directoryreadlist a folder (optionally recursive)
read_filereadread a UTF-8 file (line range; binary → metadata)
statreadsize / type / timestamps
globreadfind files by glob pattern
grepreadsearch file contents (regex / literal, context)
disk_usagereadrecursive size + file count of a tree
hash_filereadsha256 / md5 of a file
write_filemutatecreate / overwrite (diff proposal)
edit_filemutateexact-match find/replace edits (diff proposal)
append_filemutateappend text
create_directorymutatemkdir -p
movemutatemove or rename
copymutatecopy a file or tree
deletemutatedelete a file, or a tree with recursive
open_pathactionopen with the OS default app
reveal_in_osactionreveal 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.

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.

Loading diagram...

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. A startsWith(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/:

example-host/host.ts
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-host

The 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).