Skip to content
fusion-ai-host

Tools

A local tool is the same triple as any agent tool — a name, an input schema, and a handler that does the local work. defineLocalTool (re-exported from the package root) builds one. The contract is a single interface:

/**
 * A tool that runs on the user's machine. `I` is the validated input type.
 *
 * - **read / action** tools implement `execute` and run on arrival.
 * - **mutate** tools set `mutates: true` and implement BOTH `plan` (the dry-run → {@link Proposal})
 *   and `execute` (the apply). The runtime calls `plan` on a normal dispatch and `execute` only when
 *   the server re-dispatches in apply mode after approval.
 */
export interface LocalTool<I = unknown> {
	name: string;
	description: string;
	/** Zod schema for the input; the runtime validates the dispatched input against it. */
	input: z.ZodType<I>;
	/** True for tools that change the disk — gated behind propose/apply. */
	mutates?: boolean;
	/** Dry-run for a mutating tool: compute the proposal without touching disk. */
	plan?: (input: I, ctx: LocalToolContext) => Promise<Proposal> | Proposal;
	/** Run the tool (the apply, for a mutating tool). Returns JSON-serialisable output. */
	execute: (input: I, ctx: LocalToolContext) => Promise<unknown> | unknown;
}

A minimal mutating tool — plan previews the change, execute applies it:

import {  } from "@tikab-interactive/fusion-ai-host";
import {  } from "node:fs/promises";
import {  } from "zod";
 
export const  = ({
  : "rename_in_place",
  : "Rename a file within the same folder.",
  : .({ : .(), : .() }),
  : true,
  : async ({ ,  }, ) => ({
    : `Rename ${} → ${}`,
    : [await .(), await .()],
  }),
  : async ({ ,  }, ) => {
    const  = await .();
    const  = await .();
    await (, );
    return { , :  };
  },
});
  • read / action tools implement execute and run on arrival.
  • mutate tools set mutates: true and implement BOTH plan (the dry-run → a Proposal) and execute (the apply). See Propose / apply.

Every handler receives a LocalToolContext with the configured roots and a resolvePath(p) that canonicalizes a user-supplied path inside those roots — the security boundary (see Scope).

/** The context a tool's handlers receive: the host's scope + a scope-checked path resolver. */
export interface LocalToolContext {
	/** The allowed root directories this host exposes. */
	roots: string[];
	/** Resolve a user-supplied path inside the configured roots (throws if it escapes). */
	resolvePath: (p: string) => Promise<string>;
}

Built-in tools

builtinTools ships the file-system surface; domain tools are declared by the consumer and spread in: runHost({ tools: [...builtinTools, convertIfc] }).

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 (Bun.Glob)
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 (1000-entry listings, 200 grep matches / 1 KB per line, 1 MB reads), so a tool result can never blow the model's context or the channel.

Propose / apply

A mutating tool never touches disk on a normal dispatch. The runtime calls plan(input), which returns a Proposal — a summary, the resolved paths, and a unified diff where one applies — and sends it back for approval. Only when the server re-dispatches the call in apply mode does the runtime call execute(input).

Loading diagram...
/**
 * A proposal a mutating tool returns from its dry-run (`plan`). It describes the exact effect —
 * resolved paths, a human-readable diff where one applies, bytes affected — WITHOUT touching disk,
 * so a human can approve it before the apply is dispatched.
 */
export interface Proposal {
	/** One-line summary, e.g. "Overwrite report.txt (1.2 kB → 1.4 kB)". */
	summary: string;
	/** A unified-ish `+/-` diff or preview, where the change has one. */
	diff?: string;
	/** The absolute path(s) the apply will touch. */
	paths: string[];
	/** Bytes written / removed / copied, when meaningful. */
	bytes?: number;
}

Because the apply re-runs execute with the server-stored input, the client can't swap it between propose and approve; edit_file additionally re-verifies its find text still matches (a TOCTOU guard). The server/UI wiring of the two phases lives in The dispatcher.

Scope

resolveWithinRoots(path, roots) is the security-load-bearing scoper. Enforcement is canonicalization, not string matching: 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. A not-yet-existing leaf (a create/rename target) is allowed by canonicalizing its deepest existing ancestor.

No raw exec

The package ships no general shell. A raw exec 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 same power is exposed as named shell-outs: the command is fixed in the tool, only the scoped arguments vary (convert_ifc shells out to IfcConvert; open_path to the OS opener).