Skip to content
Harness Engineering2026-10-2011 min read

Make a Tool an Agent Can Operate: Ten Rules from a Retrofit

Agents lose time on tools built for people: prompts, one exit code for everything, prose output and polling. Build a small CLI that fixes each one, and check it the way an agent will.

Predict first. Your tool prompts only when a terminal is attached. Is that enough to keep an agent from getting stuck at a prompt? Decide before you read on.

Key takeaways — after your prediction
  • A terminal is not a person. Agents run in terminals too, so gate prompts on an explicit flag, not on TTY detection.
  • Give refused, not found, timed out and usage errors their own exit codes, so an agent can tell stop from retry.
  • Every report gets complete, versioned JSON, and every error names the next action.
  • A wait command that returns the job's own result replaces a polling loop the agent pays for on every turn.
  • The cheapest honest test is a fresh agent with only AGENTS.md, help --json and your released commands.

Rod Rivera

Author

Make a Tool an Agent Can Operate: Ten Rules from a Retrofit

Rod's note: read with a pencil; the margins are for you.

An agent runs your tool's doctor command. The tool sees a terminal, decides a person is there, and opens a browser login. Nobody is there. The agent waits, then guesses, then retries.

That happened with our own website tooling. We run our products with agents as the main operators, and on October 12, 2026 we audited them against one question: can an agent with no prior context do the product's main job using only the product's own commands? None of them could yet. The tools worked. They had been built for a person who already knew them.

What you will make: a 99-line Python command-line tool, jobs, that submits, inspects and waits on a background job. It has no prompts, distinct exit codes, versioned JSON, a machine-readable catalog, a wait command and errors that name the next step. You need Python 3.9 or newer and nothing else: no package, account or model.

What is executed and what is observed

The jobs tool and every output below were executed on October 12, 2026 with Python 3.14.6. The failure patterns come from audits of our own products on the same day. Those records are private, so treat them as our observations, not as results you can rerun. We have not measured how many tokens the fixes save, and we don't claim a number.

Where agents lose time on human-shaped tools

Agents rarely fail outright. They lose time and tokens working around an interface that assumes context they don't have. These are the patterns we found in our own tools:

PatternWhat the agent doesWhat we found
A prompt appears whenever there is a terminalHangs or guessesA doctor opened a browser login on any TTY; a secret prompt had no TTY check
One exit code for everythingReads stderr to tell "refused" from "failed"A release controller returned 1 for both
No command catalogReads source and old notesOnly human make help existed
Prose reports, or JSON without a versionParses text that changesA doctor printed prose; a controller's JSON had no schema version
Polling instead of waitingAsks "done yet?" every turnA controller answered "running" after one look and offered no wait
Key facts only in proseReads long records to find one hashA release hash and signature lived only in a written report

Each one is small. Together they compound. An agent that can't tell a refusal from a failure retries, each retry polls, and each poll rereads prose.

Ten rules, each against one of those costs

We wrote these down as a standard and audited every product against them, rule by rule, with one line of evidence each.

#RuleIt removes
1No prompts by default; a prompt needs an explicit --interactive flagHanging at prompts
2--json on every report, complete and versioned ("schema": "jobs.job/v1")Parsing prose
3Distinct, documented exit codesRetrying a refusal
4help --json lists every command, flag, exit code and schemaReading source
5wait blocks and returns the job's own resultPolling
6doctor checks prerequisites and names the fixTrial-and-error setup
7Every error says what failed and the next actionDigging through logs
8Running a command twice is safeFear of retrying
9An AGENTS.md page at the repository rootRebuilding context each session
10One verification gate, run by the pre-push hookGreen pushes that skipped checks

Rule 1 needs care, because it disagrees with good advice written for people. The Command Line Interface Guidelines say to prompt only when stdin is an interactive terminal. That protects scripts and pipes. It doesn't protect an agent: agents often run inside a terminal multiplexer, so a TTY is attached and nobody is reading it. The same guidelines also offer a --no-input flag. For agents, make that the default and require --interactive to turn prompts on.

Quick check — Your tool prompts only when stdin is a TTY. An agent runs it inside tmux. What happens?

Check the decision

Build it

Save this as jobs.py. It keeps state in a small JSON file and simulates a job that takes a few seconds.

python
#!/usr/bin/env python3
"""jobs: a toy job runner with an agent-first surface (standard library only)."""
import argparse, json, os, sys, time

STATE = os.environ.get("JOBS_STATE", "jobs-state.json")
OK, FAILED, USAGE, REFUSED, NOT_FOUND, TIMEOUT = 0, 1, 2, 3, 4, 5
CODES = {"ok": OK, "failed": FAILED, "usage": USAGE, "refused": REFUSED,
         "not_found": NOT_FOUND, "timeout": TIMEOUT}
COMMANDS = {
    "submit": {"flags": ["--name NAME", "--seconds N", "--json"], "schema": "jobs.job/v1"},
    "status": {"flags": ["JOB_ID", "--json"], "schema": "jobs.job/v1"},
    "wait": {"flags": ["JOB_ID", "--timeout SECONDS", "--json"], "schema": "jobs.job/v1"},
    "help": {"flags": ["--json"], "schema": "jobs.help/v1"},
}


def load():
    try:
        with open(STATE) as f:
            return json.load(f)
    except FileNotFoundError:
        return {}


def save(jobs):
    tmp = STATE + ".tmp"
    with open(tmp, "w") as f:
        json.dump(jobs, f)
    os.replace(tmp, STATE)  # atomic: never a half-written state file


def view(job):
    done = time.time() >= job["ends_at"]
    return {"schema": "jobs.job/v1", "id": job["id"], "name": job["name"],
            "state": "succeeded" if done else "running"}


def fail(kind, message, next_step, as_json):
    if as_json:
        print(json.dumps({"schema": "jobs.error/v1", "kind": kind,
                          "exit_code": CODES[kind], "message": message, "next": next_step}))
    else:
        print(f"error: {message}\nnext: {next_step}", file=sys.stderr)
    sys.exit(CODES[kind])


def emit(obj, as_json):
    print(json.dumps(obj) if as_json else f"{obj['id']} {obj['name']} {obj['state']}")


class Parser(argparse.ArgumentParser):
    def error(self, message):  # never print usage text and exit from inside argparse
        raise ValueError(message)


def main(argv):
    p = Parser(prog="jobs")
    sub = p.add_subparsers(dest="cmd", parser_class=Parser)
    s = sub.add_parser("submit"); s.add_argument("--name", required=True)
    s.add_argument("--seconds", type=int, default=2); s.add_argument("--json", action="store_true")
    st = sub.add_parser("status"); st.add_argument("job_id"); st.add_argument("--json", action="store_true")
    w = sub.add_parser("wait"); w.add_argument("job_id"); w.add_argument("--timeout", type=float, default=60)
    w.add_argument("--json", action="store_true")
    h = sub.add_parser("help"); h.add_argument("--json", action="store_true")
    try:
        a = p.parse_args(argv)
    except ValueError as e:
        fail("usage", str(e), "run: jobs help --json", "--json" in argv)
    if a.cmd in (None, "help"):
        catalog = {"schema": "jobs.help/v1", "commands": COMMANDS, "exit_codes": CODES}
        print(json.dumps(catalog, indent=2) if getattr(a, "json", False) else
              "jobs submit|status|wait|help. Run 'jobs help --json' for the full catalog.")
        return OK
    jobs = load()
    if a.cmd == "submit":
        # Idempotent: submitting the same name twice returns the existing job.
        if a.name in jobs:
            emit(view(jobs[a.name]), a.json); return OK
        if a.seconds > 3600:
            fail("refused", f"--seconds {a.seconds} is over the 3600 limit",
                 "submit again with --seconds 3600 or less", a.json)
        jobs[a.name] = {"id": a.name, "name": a.name, "ends_at": time.time() + a.seconds}
        save(jobs); emit(view(jobs[a.name]), a.json); return OK
    job = jobs.get(a.job_id)
    if job is None:
        fail("not_found", f"no job named {a.job_id!r}", f"check the name, or create it: jobs submit --name {a.job_id}", a.json)
    if a.cmd == "status":
        emit(view(job), a.json); return OK
    deadline = time.time() + a.timeout  # wait: block until done, don't make the caller poll
    while view(job)["state"] == "running":
        if time.time() >= deadline:
            fail("timeout", f"job {a.job_id!r} still running after {a.timeout}s",
                 f"run: jobs wait {a.job_id} --timeout {int(a.timeout * 2)}", a.json)
        time.sleep(0.2)
    emit(view(job), a.json); return OK


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Five choices in this file carry the rules:

  • The exit codes are a table, and the catalog prints it (rules 3 and 4). help --json returns COMMANDS and CODES, so an agent learns the tool from one call.
  • Errors are objects (rules 2 and 7). With --json, a failure prints jobs.error/v1 with its kind, exit code, message and a next field. Without --json, the same two facts go to stderr as error: and next: lines.
  • Argument errors don't escape (rule 7). Python's argparse normally prints usage text and exits with 2 from inside the parser. The Parser subclass turns that into the same structured usage error as everything else.
  • submit is idempotent (rule 8). Submitting a name twice returns the existing job. The state file is written to a temporary file and renamed, so an interrupted write never leaves half a file.
  • wait blocks with a deadline (rule 5). It returns the job's final state, or exit 5 with the exact command to wait longer. A job ID or a "running" answer is not a result, a point A Job ID Is Not a Result works through for scheduled jobs.

Check it the way an agent will

Run each command and compare the exit code. These are the results we got:

bash
python3 jobs.py help --json                  # exit 0, schema jobs.help/v1
python3 jobs.py submit --name build --json   # exit 0, state "running"
python3 jobs.py submit --name build --json   # exit 0, same job: idempotent
python3 jobs.py wait build --timeout 0.5 --json
python3 jobs.py wait build --timeout 10 --json
python3 jobs.py status nope --json
python3 jobs.py submit --name long --seconds 99999 --json
python3 jobs.py submit --bogus --json
CommandExitWhat the agent reads
wait build --timeout 0.55"kind": "timeout", "next": "run: jobs wait build --timeout 1"
wait build --timeout 100"state": "succeeded"
status nope4"kind": "not_found", "next": "check the name, or create it: jobs submit --name nope"
submit --seconds 999993"kind": "refused", "next": "submit again with --seconds 3600 or less"
submit --bogus2"kind": "usage", "message": "the following arguments are required: --name"
One exit code, one next action for the agent. 0 ok; 2 usage; 3 refused; 4 not found; 5 timeout; Agent runs a command; Exit code; Use the JSON result; Read help --json and fix the call; Stop and report to the owner; Check the name or create it; Wait again with a longer timeout
One exit code, one next action for the agent

The agent never parses the message to decide what to do. The code picks the branch, and the next field supplies the exact command.

If your exit codes match and every failure has a next line, the tool passes the parts of the standard it covers. It doesn't cover rules 6, 9 and 10: there is no doctor, no AGENTS.md and no gate. Adding them is the exercise below.

The test that finds the rest

Your own checks prove what you thought to check. To find what you didn't, give a fresh agent only AGENTS.md, help --json and your released commands, and ask it to do the product's main job. Don't give it the source.

We did this for our website's deploys. A new agent got a charter and nothing else, and was told to deploy the next release. In its first session it found nine gaps. Among them: the site exposed its live commit only as a response header, with no version or health endpoint. The release's hash and signature were written only in a narrative report. And the careful, well-tested rollback tool shipped as a make target that needs a source checkout, which a release-only agent may not use. The authors had tested all of it. They just carried the context the tool lacked.

That is the general lesson. A tool's authors are the wrong testers of its agent experience, and a fresh agent finds the gaps in one session. Our audits also said what not to claim: a catalog alone isn't compliance, and a green build isn't a deployment. A deploy is done only when the live version, read back, matches the release.

Anthropic's engineering team reaches a similar conclusion for tools exposed to models. Their post Writing effective tools for agents recommends returning high-signal information and error responses that tell the agent specifically what to change, instead of opaque codes.

Exercise

Add the three rules jobs lacks, then test the result the way an agent would.

Add a doctor (rule 6)

jobs doctor --json checks that the state file's directory is writable and that JOBS_STATE points somewhere sensible. Each failed check prints the command that fixes it. Give it its own exit code, and add the code to CODES.

Write an AGENTS.md (rule 9)

One page: what jobs does, the five commands that matter, how to verify it, and what an agent must never do. The open AGENTS.md format is a plain Markdown file at the repository root.

Add a gate (rule 10)

Turn the command table above into a test that asserts every exit code and next line, and run it from a pre-push hook.

Hand it to a fresh agent

Give a new agent only AGENTS.md, help --json and the commands, with no source access, and ask it to submit and wait on a job. Count every place it stops.

For what a schema guarantees and what it doesn't, Chapter 2 covers typed tools. Chapter 19 covers keeping a deployment alive once agents operate it.

Read Chapter 2: structured output and typed tools

Typed tools are the in-process version of this article's rules: a schema the agent can rely on, and errors it can act on.

Learn the mechanism. Test the claim.

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.