Custom harnesses
The class
Every built-in is a concrete subclass of the exported abstract Harness. Yours is the same shape, one file:
import { Harness, registerHarness, type HarnessId } from "@agntn/harnesses";
class Aider extends Harness {
/** HarnessId is a closed union in the published types; the cast is the price of a local id. */
readonly id = "aider" as HarnessId;
readonly name = "Aider";
readonly binaries = ["aider"];
readonly capabilities = {
mcp: false,
vision: true,
audio: false,
video: false,
tools: true,
streaming: true,
};
readonly config: Harness["config"] = [
{ path: "~/.aider.conf.yml", scope: "user", level: "official" },
{ path: ".aider.conf.yml", scope: "project", level: "official" },
];
readonly sessions: Harness["sessions"] = [
{ path: ".aider.chat.history.md", scope: "project", level: "official" },
];
readonly persistence: Harness["persistence"] = [{ format: "YAML", level: "official" }];
readonly instructions: Harness["instructions"] = [];
readonly skills: Harness["skills"] = [];
readonly commands: Harness["commands"] = [];
readonly hooks: Harness["hooks"] = [];
readonly detection = { envVars: [], projectMarkers: [".aider.conf.yml"] };
readonly invocation: Harness["invocation"] = {
args: ["--message", "{prompt}", "--yes-always"],
modelArgs: ["--model", "{model}"],
level: "inferred",
};
}
registerHarness(Aider);
HarnessId is a closed union in the published types, so a local id needs that cast, or a contribution upstream that adds it to the union. The registry itself is a Map keyed by whatever id you return; getHarness("aider") works at runtime once registered, in your process only. Registering an existing id replaces the built-in.
What each field decides
binariesfeedisInstalled()andversion. The first one is the invocation binary unlessinvocation.binarysays otherwise.- The six path groups are what
resolveexpands and the CLI prints. Empty is fine and honest. invocationmay benull. Theninvokerejects before spawning and all sixinvocationModesarefalse. GivenoToolsArgsonly if the CLI has a flag that removes tools from the model,readOnlyArgsonly if it enforces a sandbox itself. Prompt wording is not enforcement.modelListingneedsparseModelListingOutput(stdout)overridden to turn the CLI's output intoAvailableModel[]; the base implementation throws.mcpConfigsmakeslistMcpServers,addMcpServerand the sync see the harness. Pick the dialect that matches the file;standardis the{ command, args, env, url }family.agentsFileopts the harness intosyncAgentsFiles.
Evidence
Put level: "inferred" on a path you reasoned out and haven't seen on disk, and change it when you have. The field is required by the type, which is the point: a path without a level is a guess pretending to be a fact. Variadic CLI options go after the positional prompt in args, or they eat it.
Contributing one upstream
Built-ins live one per file under src/harnesses/. A new one needs the id in HarnessId, a row in the README table, the id in the test that pins the id list, and a contract test for its invocation templates in test/invoke.test.ts. The repository's AGENTS.md lists the run order.