AI agent harnesses
AI coding agents are converging on a common shape: a harness is the program that wraps the model -- it runs the agent loop, gives the model tools (shell, file edits, search), manages sessions and context, and renders the terminal UI. For the underlying concepts (tool use, MCP, multi-agent patterns), see the AI Agents guide; for running local models instead of cloud providers, see Local & Offline Copilot Alternative.
These are the three harnesses I currently run. Like the rest of my CLI tooling, they can be installed and version-pinned with mise.
opencode
OpenCode (GitHub) is an open-source terminal coding agent with a client-server architecture: a background service owns sessions, providers, and tool execution, while the TUI, scripts, and HTTP clients all attach to it.
- V2 highlights: client/server split with an HTTP API, background service that survives terminal restarts, 75+ providers, agents, commands, plugins, skills, and MCP support.
- Install: follow the V2 docs; the V2 CLI
binary is
opencode2(ormise use --global opencode). - Usage:
opencode2-- start the TUI in the current projectopencode2 run "explain this repo"-- one-shot print modeopencode2 service status/opencode2 service restart-- manage the background service- Configuration lives in
~/.config/opencode/opencode.json(global) andopencode.jsonper project; see the config guide.
No .zshrc additions required -- it is fully self-contained.
Adding a local provider (LM Studio)
opencode treats any OpenAI-compatible server as a custom provider. To wire up
a local LM Studio instance, add a provider entry to
~/.config/opencode/opencode.json:
{
"provider": {
"lmstudio": {
"name": "LM Studio (local)",
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://127.0.0.1:1234/v1"
},
"models": {
"qwen/qwen3.8-27b": {
"name": "Qwen 3.8 27B"
}
}
}
}
}
Two gotchas that produce confusing errors:
nameandoptionsbelong directly under the provider id (lmstudio), as siblings ofmodels-- not nested insidemodels. Nesting them insidemodelsmakes the schema validator treat"name"as a model id whose value should be an object, failing with something likeExpected object, got "LM Studio (local)".- The key under
modelsmust match the model id LM Studio actually serves. Confirm withcurl http://127.0.0.1:1234/v1/models-- a provider-prefixed alias likelmstudio/qwenwill validate fine but fail at request time if LM Studio doesn't recognize that id.
Adding a local provider (oMLX)
oMLX is a local inference server for Mac that exposes an OpenAI-compatible API
at http://127.0.0.1:8000/v1 by default. The setup mirrors LM Studio, with one
important difference: oMLX requires a real API key -- Ollama/LM Studio
accept any non-empty string, oMLX answers API key required without one.
- Find your key in
~/.omlx/settings.jsonunderauth.api_key(it starts withsk-omlx-). - Confirm the served model id (authenticated -- unauthenticated requests are rejected):
curl http://127.0.0.1:8000/v1/models -H "Authorization: Bearer <your-omlx-api-key>"
- Add the provider to
~/.config/opencode/opencode.json:
{
"provider": {
"omlx-local": {
"name": "oMLX (local)",
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://127.0.0.1:8000/v1",
"apiKey": "<your-omlx-api-key>"
},
"models": {
"Qwen3.8-27B-4bit": {
"name": "Qwen3.8-27B-4bit (local)",
"limit": {
"context": 262144,
"output": 32768
}
}
}
}
}
}
- Verify with
opencode models omlx-local, then restart the TUI and pick it via/models.
Gotchas learned the hard way:
options.apiKeyis required -- anauth.jsonentry alone is not enough for customopenai-compatibleproviders. If you prefer not to keep the secret in the config file, use"apiKey": "{env:OMLX_API_KEY}"andexport OMLX_API_KEY=<your-omlx-api-key>before starting opencode.- If you set
limit, it needs bothcontextandoutput-- a lonecontextfails config validation and blocks/modelsentirely (the error names the missinglimit.outputkey). - As with LM Studio, the key under
modelsmust exactly match theidfromGET /v1/models.
pi
pi (GitHub) is a minimal agent harness by Mario Zechner. Its philosophy: ship a small, solid core (agent loop, tools, sessions, provider auth) and let you extend the harness instead of adapting to it -- with TypeScript extensions, skills, prompt templates, and themes bundled as shareable packages.
-
Highlights: no MCP, sub-agents, or plan mode built in -- you add exactly what you want; tree-structured sessions with compaction; 15+ providers; four runtime modes (interactive, print/JSON, RPC, SDK).
-
Install:
npm install -g @earendil-works/pi-coding-agent# or, version-pinned with misemise use --global piIf
pi(or any mise-installed tool) reportscommand not found, mise itself isn't hooked into the shell yet. Confirm withmise doctor--activated: no/shims_on_path: nomeans the activation line is missing -- then add it:~/.zshrc - miseeval "$(mise activate zsh)"See Local Version Management with mise for the full setup and per-shell variants.
-
Usage:
pi-- interactive TUI in the current projectpi "fix the failing test"-- one-shot prompt/reload-- pick up extension changes mid-session; ask pi to modify its own extensions and it will- Press
Enterto steer the current run,Alt+Enterto queue a follow-up
Adding a local provider (LM Studio)
Custom OpenAI-compatible providers (Ollama, LM Studio, vLLM) go in
~/.pi/agent/models.json, which pi re-reads live -- no restart needed:
{
"providers": {
"lmstudio": {
"api": "openai-completions",
"baseUrl": "http://127.0.0.1:1234/v1",
"apiKey": "lm-studio",
"models": [
{
"id": "qwen/qwen3.8-27b",
"name": "Qwen 3.8 27B (LM Studio)"
}
]
}
}
}
baseUrlneeds the/v1suffix -- pi's OpenAI-compatible client expects it, unlike some other harnesses.- The
idmust match exactly what LM Studio serves. Confirm withcurl http://127.0.0.1:1234/v1/models-- LM Studio often prefixes ids with the publisher (e.g.qwen/qwen3.8-27b, notqwen3.8-27b). apiKeyis optional for local servers but a harmless placeholder avoids clients that reject requests with no key at all.- Switch to it with
/modelinside a session, or set it as the default in~/.pi/agent/settings.json(defaultProvider/defaultModel).
Tokens/sec footer
There's no built-in setting for this -- settings.json has no
showTokensPerSecond toggle -- but it's exactly the documented extension
pattern (ctx.ui.setFooter(), same one used for the git-branch example in
pi's own docs). Drop this in ~/.pi/agent/extensions/tps-footer.ts (global,
auto-discovered, hot-reloadable with /reload):
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
export default function (pi: ExtensionAPI) {
// message_start/message_end fire back-to-back once the response is
// finalized -- they don't bracket the real streaming duration. Time
// from the first streamed chunk (message_update) instead.
let assistantPending = false;
let firstTokenAt = 0;
let lastTps: number | null = null;
let requestRender: (() => void) | null = null;
let enabled = false;
pi.on("message_start", async (event) => {
if (event.message.role === "assistant") {
assistantPending = true;
firstTokenAt = 0;
}
});
pi.on("message_update", async (event) => {
if (assistantPending && event.message.role === "assistant" && !firstTokenAt) {
firstTokenAt = Date.now();
}
});
pi.on("message_end", async (event) => {
if (event.message.role !== "assistant") return;
assistantPending = false;
if (!firstTokenAt) return;
const message = event.message as AssistantMessage;
const outputTokens = message.usage?.output ?? 0;
const elapsedSec = (Date.now() - firstTokenAt) / 1000;
if (elapsedSec > 0 && outputTokens > 0) lastTps = outputTokens / elapsedSec;
firstTokenAt = 0;
requestRender?.();
});
pi.registerCommand("tps", {
description: "Toggle tokens/sec footer",
handler: async (_args, ctx) => {
enabled = !enabled;
if (enabled) {
ctx.ui.setFooter((tui, theme, footerData) => {
requestRender = () => tui.requestRender();
const unsub = footerData.onBranchChange(() => tui.requestRender());
return {
dispose: () => { unsub(); requestRender = null; },
invalidate() {},
render(width: number): string[] {
const tpsStr = lastTps != null ? `${lastTps.toFixed(1)} tok/s` : "-- tok/s";
const left = theme.fg("dim", tpsStr);
const right = theme.fg("dim", ctx.model?.id || "no-model");
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + pad + right, width)];
},
};
});
ctx.ui.notify("tok/s footer enabled", "info");
} else {
ctx.ui.setFooter(undefined);
ctx.ui.notify("Default footer restored", "info");
}
},
});
}
Run /tps inside a session to toggle it on. Especially useful against a
local LM Studio provider, where
throughput varies a lot by model size and quantization.
herdr
herdr (GitHub) is an agent-aware terminal multiplexer -- tmux rebuilt for running several AI coding agents at once. A background server owns the terminals, so your agents keep working when you close the lid, drop the network, or restart the machine.
- Highlights:
- Every pane is classified as
working,blocked,idle, ordone-- the sidebar tells you which agent is waiting for you instead of you polling panes - Runs Claude Code, Codex, Cursor, OpenCode, pi, Grok, and 20+ others out of the box -- it does not wrap or replace them, it owns their terminals
- Mouse-first (click, drag, right-click to split) plus tmux-style
ctrl+bprefix keys - A CLI and socket API let scripts and agents spawn panes, prompt each other, and wait until another agent is genuinely blocked
- Every pane is classified as
- Install: one binary for macOS, Linux, and Windows -- see the quick start.
- Usage:
herdr-- start or reattach to your workspace- Start any supported agent (e.g.
claude,opencode2,pi) in a pane; herdr detects it automatically ctrl+b q-- detach; everything keeps runningherdragain -- reattach, sessions are restoredherdr agent list/herdr agent explain-- inspect what herdr sees