Guide

Custom harnesses

Extend the abstract Harness class, register it, and it shows up in getHarness, detection, the CLI and the tools.

The class

Every built-in is a concrete subclass of the exported abstract Harness. Yours is the same shape, one file:

tsaider.ts
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

  • binaries feed isInstalled() and version. The first one is the invocation binary unless invocation.binary says otherwise.
  • The path groups are what resolve expands and the CLI prints. Empty is fine and honest.
  • invocation may be null. Then invoke rejects before spawning and all six invocationModes are false. Give noToolsArgs only if the CLI has a flag that removes tools from the model, readOnlyArgs only if it enforces a sandbox itself. Prompt wording is not enforcement.
  • invocation.streamArgs is for a CLI that prints nothing until the answer is done but can stream events instead. Plain runs append these flags, and foldStreamOutput(stdout, complete) turns the events back into the text the plain run prints; the base implementation throws. Claude's is the example.
  • invocation.effortArgs works like modelArgs, with {effort} in place of {model}. No effort flag in the CLI? Leave it out. Runs that ask for an effort then get rejected.
  • modelListing needs parseModelListingOutput(stdout) overridden to turn the CLI's output into AvailableModel[]; the base implementation throws. Leave searchArgs out when the CLI can't filter: a search then runs args and keeps the ids that contain it. Override modelSelector(model) too when the CLI's --model doesn't take the default provider/id, since harnesses models prints what it returns.
  • mcpConfigs makes listMcpServers, addMcpServer and the sync see the harness. Pick the dialect that matches the file; standard is the { command, args, env, url } family.
  • agentsFile opts the harness into syncAgentsFiles.
  • temp and envOverrides default to empty. An override's relocates has to name exactly the categories with entries under its path, and a test holds every built-in to that. resolve moves temp when 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.