Agent Mentor Learn
Agent Harness Fundamentals: Loops and Control · Lesson 6 of 6

Lesson 6: Hands-On: Hand-Writing an Agent Harness with Controls

Learning goals:

  • Turn the stop_reason loop from the earlier lessons into a working while loop on top of @anthropic-ai/sdk, deciding for yourself whether to keep calling tools or return text and wrap up
  • Build tool_use and tool_result content blocks exactly to spec, and send a single turn's several results back inside one user message
  • Fit that loop with four control valves — max turns, budget cap, no-progress detection, and approval for high-impact actions — and say precisely which step of the loop each one belongs at

Prerequisites: Read Lessons 2 through 5; understand the stop_reason-driven loop, stop conditions, runaway backstops, and human-in-the-loop intervention | Prev: Lesson 5 <<

First, what it looks like running

The first five lessons took the machine apart piece by piece: how the loop turns, when it should stop, what runaway looks like, how a person steps in. This lesson welds those pieces into a smallest working harness. Before any code, look at what it does in a terminal — an agent wired with two toy tools (get_time reports the time, read_file reads a file inside the project), handed one sentence: "read the first line of README.md, then tell me what time it is."

text
$ node agent.js "Read the first line of README.md, then tell me what time it is"
[turn 1] model requests tool: read_file({"path":"README.md"})[turn 1] tool returned: "# Agent Harness Fundamentals\n..."[turn 2] model requests tool: get_time({})[turn 2] tool returned: "2026-08-26T10:42:07+08:00"[turn 3] model wraps up (end_turn)
The first line of README.md is "# Agent Harness Fundamentals", and it's 10:42 on August 26, 2026.That took 2 turns of tool calls across 3 model requests.

Look closely at what happened: the user said one sentence, and how many tools got called, which one went first, and when to stop were all decided by the model inside the loop. That's the line between an agent and a workflow — a workflow's path is fixed in code, while an agent is the model dynamically directing its own process and deciding which tools to use1. The host code (the harness we're writing this lesson) never specified "read the file first, then check the time." It just faithfully turned the loop, ran whichever tool the model named, and fed the result back. Both tools here are harmless, so nothing interrupted the run — but this harness also has an approval valve welded in, and if the model reaches for something high-impact like deleting a file or firing off a request, it stops and waits for a human nod before acting (we write that later in the lesson). The rest of this lesson builds, line by line, the code behind that terminal output.

The core loop: carry the skeleton over, swap in the real SDK

The callModel from Lesson 2: The Core Loop: From One Round-Trip to Continuous Operation was pseudocode. Now it becomes the actual @anthropic-ai/sdk. The skeleton of the loop is identical: send a request carrying messages, look at response.stop_reason — if it's "tool_use", run the tools, stitch the results back, and send again; if it isn't (say, end_turn), return the text and break out of the loop2.

Here's the minimal version with no valves at all, so the loop itself stays visible:

Set this beside the Lesson 2 skeleton and the structure hasn't moved: the while line still says "repeat as long as stop_reason is tool_use," and the body is still the same four steps — push assistant, run tools, push tool_result, reassign response. The only substantive change is callModel becoming client.messages.create(...), plus that reassignment at the end of the body. That reassignment is what makes stopping possible at all; drop it and stop_reason stays at its old value forever, which is exactly the dead loop from Lesson 4: Runaway and Fallback: Dead Loops, Idle Spinning, Budget Burnout.

tool_use / tool_result fields, not one of them missing

runToolUses is where the tool the model named actually runs. The easiest thing to get wrong here is the content-block fields, so follow the spec: a tool_use block carries id / name / input, a tool_result block carries tool_use_id (claiming which call it answers) and content, and when tool execution fails you add is_error: true3. There's one more hard rule: however many tool_use blocks a response contains, that many tool_result blocks must come back, all of them packed into the single user message that immediately follows3 — the messages.push({ role: "user", content: toolResults }) line in the loop body above is what keeps that rule.

Note the try/catch: a tool blowing up shouldn't take the whole harness down with it. Wrap the error into a tool_result marked is_error: true and send it back, and the model gets a chance to retry with different arguments or take another route. That's far steadier than throwing and killing the process.

Bolting on four control valves

The loop turns now, but it's the bare loop from Lesson 2 — the one that trusts the model and leaves itself no way out. It stops on whichever turn the model returns end_turn, with no boundary anywhere in between. And an agent's autonomy means higher costs plus the potential for errors compounding around lap after lap of the loop, with the model potentially operating for many turns1 — a bare loop stakes the entire stop-or-continue decision on the model, which is too risky. Now we weld on the four valves from the earlier lessons, one at a time.

Each valve guards one thing, and none of their positions is arbitrary:

  • Valve 1, max turns (Lesson 3: Stop Conditions: When an Agent Should Quit): turns >= MAX_TURNS sits at the very top of the body, ahead of turns++. It means "before this lap, check whether another lap is still permitted." This explicit stopping condition exists so that, alongside the model's own end_turn, you keep control in your own hands1.
  • Valve 2, budget cap (Lesson 4: Runaway and Fallback: Dead Loops, Idle Spinning, Budget Burnout): every time a response comes back, add up tokens from response.usage and stop at the ceiling. When turns are few but each turn's context is enormous, turn count alone won't hold back the spend; you need tokens as a separate, independent gate.
  • Valve 3, no-progress detection (Lesson 4): flatten this turn's tool calls into a signature and compare it with the last one; identical means spinning. This catches the stagnant case where turns aren't over the limit and the budget hasn't blown, but the model is walking in place, calling the same tool with the same arguments over and over.
  • Valve 4, the approval valve (Lesson 5: Intervention and Steering: Interrupt, Redirect, Human-in-the-Loop): inside runToolUses, ahead of actually executing a tool, high-impact actions get a human confirmation first. Human-in-the-loop approval on high-impact actions is precisely the recommended way to hold down excessive-agency risk4.

Valve 3's signature function is plain to the point of dullness — join the names and arguments of every tool_use block in the turn into one string. Telling apart "what got called with what arguments" is all it needs to do:

The approval valve: wedged into the moment before execution

Of the four valves, the approval valve's position matters most and is the easiest to get wrong. It has to wedge into the moment when the model has named a tool but the tool hasn't actually run — print the action about to happen, wait for a human, execute only after confirmation. One step later and the file is already written, the request already sent, and asking "confirm?" is pointless. So it goes inside runToolUses, ahead of the impl(...) line:

approve is a function passed in from outside; in a terminal it means "print the action, read one line of input":

One detail that matters: even when the user declines, you still return a tool_result marked is_error: true rather than returning nothing. The spec requires every tool_use to have a corresponding tool_result sent back3; skip it and the next request errors out because one tool call has no result. Declining isn't the same as ignoring — a decline is itself a result the model deserves to hear about, and a model that learns it was declined will often switch to a route that doesn't need the high-impact action at all.

Two toy tools, so the loop actually runs

The valves are on; what's missing is tools the model can call. This lesson uses only two absolutely safe toys and keeps dangerous operations outside the door: get_time reports the current time, and read_file reads a file — with path.resolve pinning it firmly inside the project directory, so the model (or a model knocked off course by tool output) can't go read out-of-bounds paths like /etc/passwd:

Neither tool is in the HIGH_IMPACT set, so neither triggers approval — they're harmless by construction. To demo the approval valve, add a write_file to toolImpls and to HIGH_IMPACT. This lesson deliberately avoids introducing a real write operation so that running the example can't damage your files.

Putting it together: an entry point you can run with node agent.js

Last, gather runAgent, runToolUses, the tool definitions, and the approval function into an entry point you can run directly — the thing behind the terminal output at the top of this lesson:

Drop the preceding pieces (import, client, MODEL, runAgent, runToolUses, signatureOf, approveInTerminal, toolImpls, tools, main) into one agent.js, set ANTHROPIC_API_KEY, run npm i @anthropic-ai/sdk, and node agent.js "your task" will run.

Look back over these hundred-odd lines and you'll notice not one of them is a new concept: the while loop and stop_reason came from Lesson 2, MAX_TURNS from Lesson 3, the budget and spin detection from Lesson 4, and the approval valve from Lesson 5. A harness isn't some deep framework; it's this layer of loop-plus-valves that you write and control yourself. Same model, same two tools — but a harness with these four valves and the bare loop from Lesson 2 can differ enormously in how steadily they run the same task, because what decides whether an agent is dependable is largely this outer layer of control code, not just the model inside it5.

Keep a sense of proportion about complexity too: not every agent needs all four valves, and one line worth remembering is that you should consider adding complexity only when it demonstrably improves outcomes1. A small tool that runs three to five turns in a controlled environment might be fine with MAX_TURNS alone; four valves are for the cases that run many turns in a row and may reach for high-impact actions.

Recap

  • The core of a working harness is still the loop from Lesson 2: send a request carrying messages → check stop_reason, and if it's tool_use, run the tools, stitch a tool_result back, and send again; if it isn't, return text and wrap up2. Switching to the real SDK just turns callModel into client.messages.create(...)
  • Content-block fields follow the spec with none missing: tool_use carries id / name / input, tool_result carries tool_use_id / content plus is_error on failure; however many tool_use blocks a turn has, that many tool_result blocks come back, all packed into the one user message that immediately follows3
  • Each of the four control valves guards one spot, and their positions can't be shuffled: max turns (Lesson 3) and the budget cap (Lesson 4) are the hard boundaries that make the loop certain to stop, no-progress detection (Lesson 4) catches walking in place, and the approval valve (Lesson 5) has to wedge in ahead of tool execution — because an agent's autonomy brings higher costs and compounding errors, and the model may operate for many turns1, so the model's own end_turn can't hold it
  • The approval valve requiring human confirmation on high-impact actions is the recommended way to hold down excessive-agency risk4; even on a decline, return an is_error tool_result and don't leave the call dangling3
  • A harness isn't a deep framework; it's this layer of loop-plus-valves that you write and control yourself — same model, different control code, and reliability can differ enormously5. But don't pile valves on for their own sake either: add complexity only when it demonstrably improves outcomes1

You've finished this course. From "what a harness is" to hand-writing a loop with four control valves, what you're holding now isn't only a set of concepts — it's real code that runs, that you can edit, and that you can keep adding control to. Wire it up to your own tools and let it do some work for you.

Footnotes

  1. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2 3 4 5 6

  2. How tool use works — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/how-tool-use-works 2

  3. Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls 2 3 4 5

  4. LLM06:2025 Excessive Agency — OWASP Gen AI Security Project — https://genai.owasp.org/llmrisk/llm062025-excessive-agency/ 2

  5. The 2026 Agent Engineering Roadmap — GitHub (codejunkie99/agent-roadmap-2026) — https://github.com/codejunkie99/agent-roadmap-2026 2

Exercises

01

Right now the approval valve has two settings: ask if high-impact, allow everything else. A product colleague raises a finer requirement — control by tool name across three tiers: allow (straight through, like get_time), ask (human confirmation required before execution, like write_file), and deny (always refused, never callable at all, like a retired send_email). Add this policy valve to the harness: design its data structure, say which step of the loop it belongs at and how it relates to the existing approval valve, and write out what should go back to the model when deny hits.

Level 1: Add a per-tool tiered control valve to the harness
Done criteria · checked locally
02

A colleague says the harness loop below "works," but the moment the model stops returning end_turn on its own, or falls into walking in place, it breaks. Point out: (1) which controls it lacks and what runaway behavior each absence produces; (2) the minimal fix — at least one hard boundary that guarantees the loop will stop, with a clear statement of which step it goes at.

Level 2: Which gates is this loop missing, and how does it run away
Done criteria · checked locally