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
executeand run on arrival. - mutate tools set
mutates: trueand implement BOTHplan(the dry-run → aProposal) andexecute(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] }).
| 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 (Bun.Glob) |
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 (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).
/**
* 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).