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:

aider.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 six 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.
  • modelListing needs parseModelListingOutput(stdout) overridden to turn the CLI's output into AvailableModel[]; the base implementation throws.
  • 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.

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.

@agntn/harnesses·MIT license· Paths follow the upstream CLIs and carry their evidence level. This site reads nothing from your machine.