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 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.invocation.streamArgsis for a CLI that prints nothing until the answer is done but can stream events instead. Plain runs append these flags, andfoldStreamOutput(stdout, complete)turns the events back into the text the plain run prints; the base implementation throws. Claude's is the example.invocation.effortArgsworks likemodelArgs, with{effort}in place of{model}. No effort flag in the CLI? Leave it out. Runs that ask for an effort then get rejected.modelListingneedsparseModelListingOutput(stdout)overridden to turn the CLI's output intoAvailableModel[]; the base implementation throws. LeavesearchArgsout when the CLI can't filter: a search then runsargsand keeps the ids that contain it. OverridemodelSelector(model)too when the CLI's--modeldoesn't take the defaultprovider/id, sinceharnesses modelsprints what it returns.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.tempandenvOverridesdefault to empty. An override'srelocateshas to name exactly the categories with entries under itspath, and a test holds every built-in to that.resolvemovestempwhen the variable is set.
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. CONTRIBUTING.md has the whole checklist and the order the checks run in.