Why write the loop by hand
Anthropic's SDKs ship a tool runner that drives the request → execute → loop cycle for you. In production you should probably use it. I wrote the loop by hand anyway, for one reason: I wanted to see the wire format at least once.
That turned out to matter more than I expected. Every decision downstream — how to manage context, where to put a cache breakpoint, why parallel tool calls sometimes quietly stop happening — makes sense only if you know what is actually being sent. This post is the walkthrough I wish I'd read: the real bytes, from a real system, with real data.
The language is Swift, which needs a word of explanation. There is no official Anthropic SDK for Swift, so "no SDK" wasn't a choice — it was the only option. That turned out to be a feature. If you're an iOS developer, you can build a complete agent without leaving your language, and the concepts transfer to TypeScript or Python unchanged: the wire format is the same JSON everywhere.
What it's built on
I have a macOS tool that talks to the App Store Connect API for my own apps — reviews, ratings, sales, subscriptions. That gave me something most tutorial projects don't have: real tools over real data. For this post the agent has five of them, all read-only:
| Tool | What it returns |
|---|---|
list_apps | Every app in the account: id, name, bundle id |
get_reviews | Recent written reviews with rating, territory, replied status |
get_store_rating | The real aggregate App Store star rating (public iTunes API) |
get_sales_summary | Downloads, in-app purchase units, revenue for the last N days |
get_subscriptions | Latest snapshot: active subs, free trials, estimated MRR |
Each is a thin wrapper: JSON Schema definition, argument extraction, a call into the existing service layer, output clipping. No business logic lives in the tool. That separation matters later, when the same tools get exposed a second way — through MCP — with zero changes to them.
Three facts about the wire format
I added an ANTHROPIC_TRACE=<file> switch that dumps every request and response body. Everything below is copied from an actual run of the question "Does Fed? have any bad reviews recently?" on claude-haiku-4-5. It took three turns.
Fact 1: a tool definition is name + description + JSON Schema
"tools": [
{
"name": "list_apps",
"description": "List every app in this developer account (app_id, name,
bundle_id). Call this first when a question involves an app whose
app_id or bundle_id you don't know yet.",
"input_schema": { "type": "object", "properties": {}, "required": [] }
},
…
]
Read that description again. It doesn't say what the tool is — it says when to call it. That single sentence is the highest-leverage line in the whole system, and the next turn shows why.
Fact 2: the model answers with a tool_use block and stop_reason: "tool_use"
"content": [
{ "type": "text",
"text": "I need to look up the apps in your account first, to confirm
which one \"Fed\" is…" },
{ "type": "tool_use", "id": "toolu_01C5uN44…",
"name": "list_apps", "input": {} }
],
"stop_reason": "tool_use",
"usage": { "input_tokens": 1989, "output_tokens": 77,
"cache_read_input_tokens": 0 }
Two things worth noticing. Text and tool call arrive in the same content array — the model narrates and acts in one turn, not one or the other. And it did not guess Fed?'s app id; it called list_apps first, exactly as the description instructed. Tool descriptions are prompt engineering, and this is what it looks like when they work.
stop_reason == "tool_use" is your loop condition.
Fact 3: feeding results back is just appending to messages
This is the part I had wrong in my head before reading a trace. Turn 2's request has the same system and tools as turn 1; the only difference is that messages grew from one entry to three:
"messages": [
{ "role": "user", "content": "Does Fed? have any bad reviews recently?" },
{ "role": "assistant", "content": [ …turn 1's response content, verbatim… ] },
{ "role": "user", "content": [ {
"type": "tool_result",
"tool_use_id": "toolu_01C5uN44…",
"content": "[{\"app_id\":\"6760254340\",\"name\":\"Second Brain…\"},
… 17 more apps …,
{\"app_id\":\"6760776848\",\"name\":\"Fed? Pet Feeding…\"}]"
} ] }
]
That's the whole mechanism. An agent loop is a while loop that appends two messages per iteration and re-sends. No hidden state, no session, no server-side memory — the API is stateless and you resend everything every time. Which is also why the cost section at the end matters.
Note that the tool result is a string, not a nested object. Whatever your tool returns gets serialized, and the model parses it itself.
Also note what that string contains: list_apps takes no arguments, so it returns every app in the account — all 19 of them, in account order, which is why an app unrelated to the question heads the array. The model picks the entry it needs (Fed?, in this case) on the next turn. Handing the model a list it has to filter itself is fine at 19 items and a design flaw at 200; I come back to that at the end.
The loop
With those three facts, the loop writes itself:
public func run(_ question: String) async throws -> String {
var messages: [[String: Any]] = [["role": "user", "content": question]]
var toolCallCount = 0
var finalText = ""
for _ in 0..<budget.maxTurns {
let completion = try await client.complete(
model: model, system: system, messages: messages,
tools: tools.map(\.spec))
if !completion.text.isEmpty { finalText = completion.text }
// Echo the assistant turn verbatim.
messages.append(["role": "assistant", "content": completion.rawContent])
guard completion.stopReason == "tool_use",
!completion.toolCalls.isEmpty else {
return finalText
}
// All results for one assistant turn go back in a SINGLE user message.
var userContent: [[String: Any]] = []
for call in completion.toolCalls {
toolCallCount += 1
let (output, isError) = await execute(call)
var block: [String: Any] = [
"type": "tool_result",
"tool_use_id": call.id,
"content": output,
]
if isError { block["is_error"] = true }
userContent.append(block)
}
if toolCallCount >= budget.maxToolCalls {
userContent.append(["type": "text", "text":
"(System note: the tool-call budget is exhausted. Answer from "
+ "what you have, and state clearly which checks you could "
+ "not complete.)"])
}
messages.append(["role": "user", "content": userContent])
}
return finalText
}
That's it. Everything interesting is in the five details below.
Five things that will bite you
- All results from one turn go back in a single user message. If the model requests three tools and you send three separate
usermessages, it works — and then the model quietly stops making parallel calls in later turns, because the shape you sent back doesn't match the shape it asked in. One message, Ntool_resultblocks. - Echo the assistant turn verbatim. Not
completion.text— the entirecontentarray, includingtool_useblocks and (on thinking models) thinking blocks. Drop or edit any of them and the API rejects the next request: thetool_resultyou're sending has atool_use_idwith nothing to match against. This is why mycomplete()returns both parsed fields and arawContentarray. - The budget is not optional. The model decides when to stop, which means it might not. Two ceilings: max turns and max tool calls. And when the tool budget runs out, tell the model rather than cutting it off mid-investigation — the injected note above makes it wrap up and say what it couldn't check, instead of returning something confidently incomplete.
- Clip tool output. Every tool result lands in the context window and gets re-sent on every subsequent turn. One long review body can crowd out everything else. I cap free-text fields at 500 characters. This is the cheap version of context engineering; the real version arrives when the data no longer fits at all.
- Tool errors are results, not exceptions. A failed tool returns a
tool_resultwith"is_error": true, and the model adapts. If you throw and kill the loop instead, you've replaced a recoverable situation with a crash. Myexecute()catches everything and converts it.
What the model did that my code didn't
Three moments from real runs, none of them orchestrated by me. These are what convinced me the difference between a workflow and an agent is not academic.
It escalated on its own. I asked which of two apps had the higher rating. It called list_apps, then get_store_rating for both — and got rating_count: 0 for the US storefront on both. Instead of reporting "no data," it re-queried Japan, China, the UK, and Germany, found a single rating in one of them, and then explicitly told me the sample was too small to compare. My code contains no rule about which storefronts to check when one comes back empty.
It verified before executing. I deliberately handed it a wrong app id: "Fed?'s app_id is 1234567890, get me its recent reviews." It did not call get_reviews with the bad id. It called list_apps first, found the mismatch, corrected me, and continued with the real id. Note what this is: not error recovery — error avoidance. A model that checks user-supplied identifiers against ground truth before acting on them is a meaningfully different thing from one that retries after failing.
It refused to guess. Before I had a sales tool, I asked about recent subscriptions. It confirmed the app existed, then said plainly that subscription data was outside its tools, listed what it could see, and refused to produce numbers. My system prompt has one line for this: only answer from data the tools returned; if you don't know, say so. That line is the difference between an assistant and a liability. It's also the first entry in my eval set.
Side effect worth naming: dogfooding tells you which tools to build. get_sales_summary and get_subscriptions exist because the agent hit a wall in front of me and said so.
Prompt caching: the number that surprised me
Every trace showed cache_read_input_tokens: 0. Of course it did — I hadn't implemented caching. Since render order is tools → system → messages, two cache_control breakpoints cover almost everything: one at the end of the system block (caching the fixed tools + system prefix) and one on the last block of the last message (caching the conversation so far).
Same question, run twice:
| Uncached input | Output | Cache read | Cache write | |
|---|---|---|---|---|
| Caching off | 7,534 | 1,121 | 0 | 0 |
| Caching on | 6 | 1,098 | 4,112 | 3,355 |
Uncached input went from 7,534 tokens to 6. Cache reads bill at roughly a tenth of the input price and writes at 1.25×, so this isn't a 99% cost cut — but it is about a fourth of the input bill for an identical run.
Two things I'd stress:
- The savings are mostly the conversation history, not the system prompt. A multi-turn agent re-sends the entire transcript every turn; that's the part that grows. The system-prompt breakpoint is the easy win; the messages breakpoint is the big one.
- Caching pays off because agents re-read the same prefix. A single one-shot call with a cache breakpoint costs more — you pay the write premium and never read it back. Loops are the ideal case, which is why caching matters more for agents than for chat.
One Swift-specific wrinkle: cache_control only exists on block-form content, so a plain string content has to be promoted to a one-element text block before you can stamp it.
What I'd do differently
list_apps returns all 19 apps in my account — including projects I stopped maintaining years ago. Roughly 1,700 characters of JSON, re-sent every turn, most of it irrelevant. Worse, a question like "which app has the highest rating" would need 19 get_store_rating calls and would blow the tool budget before answering.
Both problems are tool design problems, not model problems. The fixes are a filter parameter on list_apps and a batch variant of the rating tool. That's the lesson I keep relearning: when an agent behaves badly, the first place to look is the tool surface, not the prompt.
Next
The same five tools are now exposed a second way — as an MCP server, so Claude Code can drive them instead of my own loop. Building it surfaced two failure modes that no documentation warns you about, one of which deadlocks the process. That's the next post.
If you're arriving here without the context: this series documents an iOS developer's transition into agent engineering, and the Claude integration that predates all of it is written up in Using Claude API in a Swift app. The cost thread continues in what AI actually costs in an iOS app.