AI agents and tool calling in .NET: the loop, written by hand
Short answer: an AI agent is a model that can call tools, functions you define, in a loop: you send the task and the tool definitions, the model replies with a request to call a tool, your code runs it and sends the result back, and this repeats until the model answers without asking for a tool. Tool calling is the mechanism; the loop is what makes it an agent. Keep it safe with three rules: few and narrow tools, a cap on turns, and a human approving anything that can't be undone.
A real example: answering "where is my license?"
A customer writes: "Why does order GH56JK78MN have no license yet?" A good answer needs the order's real status, which only your system knows. The assistant should look it up, then write a short, accurate reply. It must not promise a refund, and it shouldn't send anything on its own.
So it gets two tools: get_order_status (read-only) and draft_reply, which saves a draft for a human instead of sending email. The model decides when to call each one; your code decides what each one is allowed to do.
How tool calling works
- You send the conversation plus a list of tools, each with a name, a description and a JSON Schema for its input.
- If the model needs a tool, the response contains one or more
tool_useblocks and the stop reason istool_use. - Your code runs each requested tool and sends all results back in the next user message, each matched to its
tool_useid. Errors go back too, marked as errors, so the model can correct itself. - Repeat until the stop reason is
end_turn.
The model never runs anything itself. It only asks; your code is the gatekeeper.
Runnable code: the loop by hand
Create a console app, dotnet add package Anthropic, set ANTHROPIC_API_KEY, and replace Program.cs:
using System.Text.Json;
using Anthropic;
using Anthropic.Models.Messages;
AnthropicClient client = new();
Tool[] tools =
[
new Tool
{
Name = "get_order_status",
Description = "Returns the status and product of a shop order. Read-only.",
InputSchema = new()
{
Properties = new Dictionary<string, JsonElement>
{
["code"] = JsonSerializer.SerializeToElement(new { type = "string", description = "10-character order code" }),
},
Required = ["code"],
},
},
new Tool
{
Name = "draft_reply",
Description = "Saves a reply to the customer as a draft for a human to review. Does not send anything.",
InputSchema = new()
{
Properties = new Dictionary<string, JsonElement>
{
["text"] = JsonSerializer.SerializeToElement(new { type = "string" }),
},
Required = ["text"],
},
},
];
List<MessageParam> messages =
[
new UserMessageParam { Content = "A customer asks why order GH56JK78MN has no license yet. Check it and draft a short reply." },
];
// The agent loop: call the model, run the tools it asks for, send the results back, repeat until it stops asking.
for (var turn = 0; turn < 10; turn++)
{
var response = await client.Messages.Create(new MessageCreateParams
{
Model = "claude-opus-5",
MaxTokens = 4096,
System = "You are a support assistant for a small software shop. Use the tools; never promise refunds.",
Tools = [.. tools],
Messages = messages,
});
List<ContentBlockParam> assistant = [];
List<ContentBlockParam> results = [];
foreach (var block in response.Content)
{
if (block.TryPickText(out var text))
{
assistant.Add(new TextBlockParam { Text = text.Text });
Console.WriteLine(text.Text);
}
else if (block.TryPickThinking(out var thinking))
{
assistant.Add(new ThinkingBlockParam { Thinking = thinking.Thinking, Signature = thinking.Signature });
}
else if (block.TryPickToolUse(out var call))
{
assistant.Add(new ToolUseBlockParam { ID = call.ID, Name = call.Name, Input = call.Input });
var (output, failed) = Run(call.Name, call.Input);
results.Add(new ToolResultBlockParam { ToolUseID = call.ID, Content = output, IsError = failed });
}
}
if (response.StopReason != "tool_use")
{
break; // end_turn (done), max_tokens or refusal: stop and let a human look.
}
messages.Add(new AssistantMessageParam { Content = assistant });
messages.Add(new UserMessageParam { Content = results }); // all results of this turn in one message
}
static (string Output, bool Failed) Run(string name, IReadOnlyDictionary<string, JsonElement> input)
{
try
{
return name switch
{
"get_order_status" => (input["code"].GetString() == "GH56JK78MN" ? "Pending: payment not matched yet" : "Not found", false),
"draft_reply" => (SaveDraft(input["text"].GetString()!), false),
_ => ($"Unknown tool {name}", true),
};
}
catch (Exception ex) when (ex is KeyNotFoundException or InvalidOperationException)
{
return ($"Bad input: {ex.Message}", true); // the model reads this and can correct itself
}
}
static string SaveDraft(string text)
{
File.AppendAllText("drafts.txt", text + Environment.NewLine + "---" + Environment.NewLine);
return "Draft saved for review.";
}
The details that make it work:
- The assistant turn is echoed back exactly, including tool calls and any thinking blocks (with their signatures), so the next request has the full history.
- All tool results of a turn go back in one user message. The model may ask for several tools at once; answer them together.
- Errors are results, not exceptions.
IsError = truewith a readable message lets the model fix its own mistake, such as a malformed code. turn < 10is the brake. A confused agent can loop; a cap turns that into a visible failure instead of a bill.draft_replydrafts, it doesn't send. The most important safety decision is in the tool design, not the prompt.
Workflow first, agent second
Before building an agent, ask whether the steps are known. "Look up the order, then write a reply" is a fixed sequence: two calls in your own code (a workflow) are cheaper, faster and easier to test. Reach for an agent when the path depends on what the model finds: which tools, how many times, in what order. Many good products are workflows with one agent-shaped step in the middle.
Once you've written this loop by hand, you'll recognise it everywhere: in frameworks, in the SDK's tool runner, and in MCP clients, which run the same loop over tools from an MCP server.
Pros and cons of agents
Pros
- Handle tasks whose steps can't be scripted in advance.
- Combine your data and actions with the model's reasoning, in one loop.
- The same tools serve many tasks, without new code per task.
Cons
- More model calls mean more cost and latency than a fixed workflow.
- Harder to test: the path changes between runs, so you need evals, not only unit tests.
- Every tool widens what can go wrong; text in emails or web pages can try to steer the model, so narrow tools and human approval matter.
Next steps
Log every tool call with its arguments and result, build a small set of real tasks to re-run after each change, and only then add more tools. The whole path is in the AI engineering roadmap; typed output, which every tool result benefits from, is in LLM API basics.
Want an assistant that works with your systems but leaves the risky decisions to people? See how I can help.
Frequently asked questions
What is the difference between a workflow and an agent?
In a workflow your code decides the steps and calls the model at fixed points. In an agent the model decides which tool to call next, in a loop. Workflows are cheaper and more predictable; use an agent only when the steps can't be known in advance.
Should I use an agent framework?
Write the loop by hand once first; it is short. Then a framework or the SDK's tool runner can save boilerplate, and you will know what it does and where to add approval, logging and limits.
How do I stop an agent from doing something harmful?
Give it only the tools it needs, make risky tools draft or propose instead of act, require human approval for anything irreversible, cap the number of turns, and log every tool call with its arguments.