Skip to content

Writing a tool

This guide walks through building one real tool, editFile, from examples/tools.ts.

Before writing any tool, your app declares its dependencies by augmenting LoopyDeps:

deps.ts
export interface GitRepo {
read(path: string): Promise<string>;
write(path: string, content: string): Promise<void>;
find(query: string): Promise<readonly string[]>;
}
declare module "@loopyjs/core" {
interface LoopyDeps {
repo: GitRepo;
}
}

This is a one-time step per dependency — see Dependency injection for why loopy requires it to be explicit rather than inferred.

A tool’s input/output are IO<...> schemas. The built-in io<T>() gives you a static type with an identity validator — swap in Zod, Valibot, or ArkType for real runtime coercion:

import { io } from "@loopyjs/core";
const input = io<{ path: string; find: string; replace: string }>();
const output = io<{ applied: boolean }>();
import { tool, io } from "@loopyjs/core";
export const editFile = tool({
name: "editFile",
description: "Apply a find/replace edit to a file.",
input: io<{ path: string; find: string; replace: string }>(),
output: io<{ applied: boolean }>(),
deps: ["repo"],
run: async (i, { deps }) => {
const cur = await deps.repo.read(i.path);
await deps.repo.write(i.path, cur.replace(i.find, i.replace));
return { applied: true };
},
});

deps: ["repo"] is what makes deps.repo available inside run. Try deps.figma here — a dependency this tool never declared — and TypeScript rejects it with TS2339, at the call site, before anything runs.

4. Add an idempotencyKey for non-idempotent effects

Section titled “4. Add an idempotencyKey for non-idempotent effects”

loopy’s durability model is at-least-once: a tool call that crashes mid-flight gets re-issued on recovery. editFile’s replace-in-place is naturally safe to run twice — in the common case, the same find/replace applied to already-edited content is a no-op the second time. A tool like createFile isn’t: running it twice could double-create or clobber. Declare idempotencyKey so a re-issued call can be recognized once the runtime implements deduplication:

export const editFile = tool({
// ...
idempotencyKey: (i) => `edit:${i.path}:${i.find}`,
// ...
});

Think of the key as “what makes this call the same call if it happens again.” It’s usually a stable hash of the meaningfully-identifying input fields, not the whole input object.

editFile is a fully typed Step. Its name is preserved as the literal "editFile", its dependency requirement is exactly "repo", and its run signature is (input: { path: string; find: string; replace: string }, ctx: ToolCtx<"repo">) => Promise<{ applied: boolean }>. It can now be passed into an agent()’s tools array or used directly as a workflow() node.