Lesson 6: Hands-On: Hand-Writing an Agent Harness with Controls
Learning goals:
- Turn the
stop_reasonloop from the earlier lessons into a workingwhileloop on top of@anthropic-ai/sdk, deciding for yourself whether to keep calling tools or return text and wrap up- Build
tool_useandtool_resultcontent blocks exactly to spec, and send a single turn's several results back inside oneusermessage- 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."
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_TURNSsits at the very top of the body, ahead ofturns++. It means "before this lap, check whether another lap is still permitted." This explicit stopping condition exists so that, alongside the model's ownend_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.usageand 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→ checkstop_reason, and if it'stool_use, run the tools, stitch atool_resultback, and send again; if it isn't, return text and wrap up2. Switching to the real SDK just turnscallModelintoclient.messages.create(...) - Content-block fields follow the spec with none missing:
tool_usecarriesid/name/input,tool_resultcarriestool_use_id/contentplusis_erroron failure; however manytool_useblocks a turn has, that manytool_resultblocks come back, all packed into the oneusermessage 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_turncan'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_errortool_resultand 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
-
Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
How tool use works — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/how-tool-use-works ↩ ↩2
-
Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls ↩ ↩2 ↩3 ↩4 ↩5
-
LLM06:2025 Excessive Agency — OWASP Gen AI Security Project — https://genai.owasp.org/llmrisk/llm062025-excessive-agency/ ↩ ↩2
-
The 2026 Agent Engineering Roadmap — GitHub (codejunkie99/agent-roadmap-2026) — https://github.com/codejunkie99/agent-roadmap-2026 ↩ ↩2