Running another harness
invoke
import { getHarness } from "@agntn/harnesses";
const codex = getHarness("codex");
const result = await codex.invoke("Review this patch for races", {
readOnly: true,
timeoutMs: 60_000,
});
result.command; // "codex"
result.args; // ["exec", "--skip-git-repo-check", "--sandbox", "read-only", "Review this patch for races"]
result.stdout; // whatever Codex printed
result.exitCode; // number, or null when stopped by a timeout or a signal
invoke expands the harness's argument template, spawns the binary with stdio piped, and resolves with the captured output. An exit code other than zero is a result, not an exception. It does throw when the requested mode has no template, before anything is spawned. It also throws when the spawn fails. Node's spawn claude ENOENT reads the same for a missing binary and a missing working directory, so the message names which one it is. code stays Node's. buildInvocation(prompt, options) gives you the command without running it, invocationError(options) the reason it would be rejected, or null.
There's one more refusal. invoke sets AGNTN_HARNESSES_PARENT to the harness id on the process it starts, and Grok, for one, passes its environment to its MCP servers. So when that harness calls harnesses_run back, the nested invoke throws before spawning anything. That's on purpose. Grok in read-only mode calling Grok through the MCP server got a child with no network in its sandbox, which retried quietly until the deadline, and the caller saw nothing but silence.
Modes
tools defaults to false. That gives an advisor: the harness with its tools removed by a native CLI flag, so the model can only answer. It is not an agent told to behave by prompt wording.
| Option set | Mode | Needs |
|---|---|---|
{} | advisor | noToolsArgs |
{ structured: true } | advisor, JSON output | noToolsJsonArgs |
{ readOnly: true } | full agent in a native read-only sandbox | readOnlyArgs |
{ readOnly: true, structured: true } | same, JSON output | readOnlyJsonArgs |
{ tools: true } | full agent | args |
{ tools: true, structured: true } | full agent, JSON output | jsonArgs |
readOnly implies tools when you leave tools out. An explicit tools: false beside it is rejected, since a read-only agent still has tools. A harness that lacks the template for the requested mode rejects the call and says which retry would work:
Harness gemini has no read-only full agent invocation
Harness codex has no advisor without tools invocation; retry with tools: true to start its full agent
Codex has no advisor because codex exec cannot switch its tools off. OMP has --no-tools, but it only disables the bundled tools, so it doesn't count either. Claude's --tools "" with --strict-mcp-config does. harness.invocationModes tells you the six booleans up front, and every harness page lists its templates. Nothing falls back to a wider mode, ever.
Taking the tools away doesn't tell the model they're gone, though. Ask a toolless Claude to hash a string in node and it writes the <invoke name="Bash"> block itself, as plain text. In longer runs it then reports what that run returned. Nothing ran. So Claude's advisor templates append a system prompt saying there are no tools here. Any advisor answer that still holds an <invoke> or <function_calls> tag comes back with toolCallsAsText: true. harnesses_run adds a warning for the model, harnesses run prints it on stderr. The exit code stays put, because the rest of the answer can still be fine.
Read-only can take the network too. Grok's sandbox cuts it on Linux for everything Grok starts, MCP servers included, while its own search keeps working. So the run finishes and looks fine. And the web tools it leaned on? Dead, every one. harness.readOnlyNetwork() says so before the run: false for Grok on Linux, true on macOS. null means nobody checked, not that the network is there. A run that needs the web wants tools: true without readOnly.
Models
const pi = getHarness("pi");
const { models } = await pi.listModels({ search: "gpt-5.4" });
models[0]; // { provider: "openai-codex", id: "gpt-5.4", contextWindow, maxOutputTokens, thinking, images }
await pi.invoke("Review this patch", { model: "openai-codex/gpt-5.4", readOnly: true });
model appends the harness's modelArgs. A harness without them rejects the option. listModels runs the native listing command and normalizes its output. Pi, Prime Agent, OMP, Grok, Antigravity and Codex have one today. Pi, Prime Agent and OMP list only providers with configured authentication. OMP prints JSON, so there's no table to parse, and it lists chat models only. Grok prints bare ids with the default marked, so its models carry default and none of the size or feature fields. Antigravity prints an id and a display name with no default, so its models carry the id and the google provider only. Codex dumps its whole catalog as JSON. Its models carry a display name in name. search goes to the CLI when it has a filter of its own. Grok, Antigravity and Codex don't, so there the ids are matched locally, ignoring case. A harness without modelListing rejects the call the same way.
Reasoning effort
Ever asked for a yes or no and watched the harness think for seven minutes?
await getHarness("grok").invoke("Is this regex fine?", { readOnly: true, effort: "low" });
effort appends the harness's effortArgs for that one run. Your config stays untouched. Claude, Codex, Grok, Pi and OMP take it today. The word goes through as is, in the harness's own vocabulary. So xhigh means something to Grok and Claude, minimal to Pi. A harness without effortArgs rejects the option, same as model. Anything that isn't one lowercase word is rejected too. An effort can't smuggle in a second flag.
What happens to a word the CLI doesn't know is the CLI's call. Grok and OMP refuse to start. Claude and Pi print a warning and think as hard as their config says. Codex takes it and prints it in its header.
Timeouts and cancellation
timeoutMs and signal work the same on invoke and listModels. Unset or 0 means no deadline. An already aborted signal skips the spawn. Otherwise the first of cancellation or deadline starts cleanup:
- Linux and macOS: the child runs in its own process group;
SIGTERMto the group, thenSIGKILL500 ms later, even if the root already exited. - Windows:
taskkill /T /Fat once, with a 2 s budget for that command.
A stopped result keeps the output captured so far and has exitCode: null. aborted is true when your signal won, timedOut when the deadline did; the first reason wins and later aborts do nothing. Cleanup failures reject the promise.
idleMs on a stopped result says how long both streams had been silent before the stop. A few milliseconds means the model was still writing; the whole deadline means it never printed anything. Plain claude -p holds its answer until the end, so a plain Claude run streams events with --output-format stream-json and folds them back: a finished run returns the same text as before, a stopped one the text written so far. Structured runs keep the single final JSON.
const controller = new AbortController();
const pending = pi.invoke("Review this change", { tools: true, readOnly: true, signal: controller.signal });
controller.abort();
(await pending).aborted; // true
This is command cleanup, not a sandbox. A descendant that leaves the process group, or outlives an already exited root on Windows, is out of reach. Output pipes inherited by such a descendant do not extend the wait.
In the CLI
harnesses run claude "review this design" # advisor
harnesses run pi --model openai-codex/gpt-5.4 "review" # advisor, chosen model
harnesses run codex --read-only "review this" # native read-only sandbox
harnesses run grok --read-only --effort low "quick one" # think less, this run only
harnesses run claude --tools --json "fix lint" # full agent, structured output
harnesses models pi gpt-5.4 --json
--timeout is in seconds. A timed out run exits 124, otherwise the harness's own exit code passes through.