Generated from
cat-harness/skills/folio-core/tool-authoring.md— do not edit here.
Tool Authoring — from capability to concrete mechanism
Skill id:
tool-authoring· Package:folio-coreCompanion:skills-and-tools
A skill states a capability generically. A Tool node in the Knowledge Graph carries the concrete specification, and the tools repository contains the executable implementation.
When authoring or modifying tools in the separated architecture:
- The Skill (
skills/) defines what an agent or user accomplishes, free from vendor or CLI specifics. - The Tool Node (
cat-harness/tools/) declares the tool in the Knowledge Graph with its I/O contracts, capability bindings (satisfies), and execution metadata. - The Concrete Implementation lives in the dedicated tools repository
(the tools layer or
<name>-tools/), maintaining its own standalone dependencies, build scripts, ambient type definitions, and test suites.
1. Repository Boundary & Layout
The platform splits content/schemas from tool implementations:
| layer | repository | role & contents |
|---|---|---|
| Graph & Schemas | litlfred/cat-harness | Declarative Tool nodes in tools/ (using defineTool from schemas/tool.ts), schema definitions, and skill contracts. |
| Tool Execution | the tools layer’s repository | Executable TypeScript implementations in src/tools/, MCP server in src/server.ts, ambient moddle/type definitions in types/, standalone package.json (@litlfred/<tools-layer>), tsconfig.json, and bunfig.toml. |
This split guarantees that a content-only consumer or reader does not drag along heavy runtime dependencies, SDKs, or compiler toolchains.
2. Step-by-Step Tool Authoring Lifecycle
Step 1: Identify or Author the Skill Contract
Before writing code, identify the generic capability the tool satisfies:
- Find the skill in
skills/(e.g.,skills/sdlc/sdlc-core/package-release.md). - If the skill accepts inputs, verify or define its
input:JSON Schema contract in the skill’s front matter or underschemas/skills/.
Step 2: Implement the Handler in the tools layer
Create a dedicated module under cat-harness-tools/src/tools/<tool-name>.ts:
- Use Zod for input validation: Define explicit input schemas with descriptions.
- Support three-state returns:
success: action completed with verified outcome.failure: action failed with actionable error message.undetermined: prerequisites or state could not be inspected — never report “clean” or “success” when an audit or check was skipped.
- Keep handlers deterministic and pure where possible: Isolate file system writes or network calls behind explicit arguments or testable adapters.
Example handler:
import { z } from "zod";
export const PackTarballInputSchema = z.object({
instanceRoot: z.string().describe("Root directory of the instance to pack"),
outDir: z.string().optional().describe("Output directory for the .tgz tarball"),
});
export type PackTarballInput = z.infer<typeof PackTarballInputSchema>;
export async function packTarball(input: PackTarballInput) {
// Implementation...
}
Step 3: Register with the MCP Server
In cat-harness-tools/src/tools/ or cat-harness-tools/src/server.ts:
- Export a registrar function:
register<ToolName>Tools(server: McpServer). - Register the tool with
server.tool(name, description, schema, handler). - Re-export the tool from the tools layer’s
src/index.tsif it is part of the public programmatic API.
Step 4: Declare the Tool Node in the Knowledge Graph
In tools/<id>/<id>.ts, one folder per Tool (Step 4b):
- Default-export
toolFactory((t) => defineTool({ … }))(schemas/tool-folders.ts,schemas/tool.ts). - Link to the skill via
satisfies: ["<skill-id>"]. - Map I/O ports to shared vocabulary in
schemas/tool-types.tsvia absolute IRIs. - Specify invocation target:
- In-process:
invoke: { inProcess: "@litlfred/<tools-layer>#<export>" } - CLI:
invoke: { command: "bun run ...", args: [...] }
- In-process:
Example:
// tools/pack-tarball/pack-tarball.ts
export default toolFactory((t) => defineTool({
id: "pack-tarball",
satisfies: ["npm-kg-distribution", "package-release"],
title: "Pack Knowledge Graph into npm tarball",
description: "Runs `bun pm pack` to build a deterministic .tgz tarball.",
io: {
inputs: [
{ name: "instanceRoot", type: "https://schema.org/Text", required: true },
],
outputs: [
{ name: "tarballPath", type: "https://schema.org/Text", required: true },
{ name: "sha256", type: "https://schema.org/Text", required: true },
],
},
invoke: {
command: "bun cat-harness-tools/scripts/pack-tarball.ts",
},
}));
Step 4b: A new folder is a named subgraph of its harness — declare it
If what you are adding needs a new folder, decide first which of three kinds of folder it is. Each has exactly one way to be declared, and the place the folder sits decides which (owner, 2026-10-14):
- A folder directly under the harness root (a new code area, a new
graph): an entry in the harness’s own declaration (
<harness>.json, e.g.cat-harness.json), underdirectories, with anid, thepath, itsgraphTypologiesand a one-linedescription. - A folder inside a folder the harness already declares: declared
from within, in that directory’s own
<dir>.json(asskills/skills.jsondeclaresvoices). This is not a matter of taste:check:layout-normsfails a new top-level entry whose path lies inside another declared directory (the pairs it only lists are a baseline). Addsubgraph: truewhen the new folder is a subgraph of the INSTANCE rather than a part of its parent’s graph (beancmsl:voicesis the first kind,beans.json’sdefsthe second). - A folder that holds ONE node — one Tool, one voice: it is not
declared. It sits inside the declared graph that owns the node and is
found through that graph’s reader, as each voice folder is found by its
voice.json. Never declare one Tool’s folder as atoolsdirectory: discovery loads ONEtools/index.tsper harness and refuses a harness that declares twotoolsdirectories. The Tool’s folder is found by thatindex.ts(toolsFromFolders), as each voice is by itsvoice.json.
Both declared forms say what a folder holds the same way, by its
graphTypologies: a from-within entry names its own kind (voices.json
declares vendors as voice-vendors), it does not inherit its parent’s.
One folder per Tool, always (bean tdir, owner 2026-10-15: “migrate
all impacted repos … to sub-folders as is strongly recommended behaviour”).
A Tool is declared in tools/<id>/<id>.ts inside the harness’s declared
tools directory: the folder, the file stem and the Tool’s id are one
string, the file default-exports toolFactory((t) => defineTool({ … })), and
the folder also holds whatever is that Tool’s alone (its script, fixtures,
tests). tools/index.ts only calls toolsFromFolders
(schemas/tool-folders.ts), which refuses a folder that breaks the
convention, so discovery is unchanged. Helpers several Tools share go in
tools/lib.ts, which declares no Tool. A defineTool( anywhere else in the
directory is an inline declaration, and check:tools reports it.
The source stays TypeScript on the default branch; the JSON-LD of each Tool
(<base>/tools/<id>.jsonld, its own IRI) and the JSON Schema of each I/O type
are written when the site is published, never committed.
Why declaring is part of the process, not tidying:
- A folder nothing declares is a folder nothing reasons about. No check
catches it for you:
check:undeclared-filessweeps the repository root only, so a new undeclared folder inside a harness passes it. - Declared directories are what can be mounted and exported. A remote
mount lays down declared directories only, so an undeclared code folder is
missing downstream (the tools layer’s
tools/was, until 2026-10-14). A harness’s packageexportsare derived from its declared, shippedcodedirectories (check:package-exports, bean7pvn), so an undeclared one cannot be imported by package name either. A from-within id cannot yet be mounted on its own:remote-mountreads top-level entries only (bean2j2r, needed bymo75). bun run cat harness:dirsthen materialises every declared directory, so the folder exists in every checkout rather than only in yours.
Step 5: Test the Tool
Author fast unit tests in the tools layer’s test/ or whole-checkout integration tests in the tools layer’s test/coordinator/:
- Test valid arguments and expected outputs.
- Test invalid inputs and error handling.
- Verify with
bun test <tools-layer>/test/<tool>.test.tsorbun test <tools-layer>/test/coordinator/<test>.test.ts.
Step 6: Validate Reachability and Skill Coverage
Run verification gates:
bun run cat check:tools # verifies satisfies edges match existing skills & types agree
bun run cat tools:coverage # checks automated coverage across skills
3. Toolchain & Ambient Types Isolation
Ambient Type Definitions: If a tool uses external libraries that lack TypeScript types (such as Moddle XML parsers like
bpmn-moddleordmn-moddle), place ambient declaration files incat-harness-tools/types/*.d.tsand include them in the tools layer’stsconfig.json. Never place tools-specific ambient types in content-only subgraphs.Bundled Dependencies: Tools declare their runtime dependencies in the tools layer’s
package.json. Subgraphs that only import schemas or markdown docs must not list tool runtime dependencies in their manifests.Packaging: The tools package is packaged and published using
bun pm packas@litlfred/<tools-layer>.