Build a Claude Tool-Use Agent in C#: Not a Chatbot on Steroids
Everyone ships "AI agents" now. Peek under the hood of most of them and you'll find a chatbot with a personality prompt and a hopeful // TODO: make it do things. It can talk about your orders. It cannot actually look one up.
A real agent closes that gap. You hand the model a set of tools — plain methods in your codebase — and it decides, mid-conversation, to call one. Your code runs the real work, hands back the result, and the model keeps going until it can answer for real. That loop is the whole trick, and you can build it in C# in about sixty lines. We'll do it twice: once by hand with HttpClient so you can see the wire protocol, and once with the official Anthropic .NET SDK for the short version you'd actually ship.
What "tool use" actually is
Tool use (a.k.a. function calling) is a small, strict loop:
-
You declare tools. Each is a
name, adescription, and a JSON Schema for its inputs. That's it — no code is sent to Claude, just the shape. -
Claude replies with a
tool_useblock instead of text. It's saying "please runcheck_stockwith{ "sku": "ETH-250" }and tell me what you get." - Your code runs it. This is the part people miss: Claude never executes anything. It requests; your harness executes. The security boundary stays on your side of the fence.
-
You send the answer back as a
tool_resultblock, tied to the request by id. - Claude continues — maybe calling more tools, maybe answering. You loop until it stops asking.
Think of Claude as a very fast senior colleague who can read your API but isn't allowed to touch production: it tells you exactly which button to press, you press it, you report back. The judgment is the model's; the hands are yours.
The agent we're building
Meet Aurora Coffee Co., a fictional shop with a support agent that answers questions like "Is my order shipped, and do you still have the Ethiopia beans?" It has two tools over some in-memory data, so the whole thing runs with zero external setup:
-
get_order_status(order_id)— looks up an order. -
check_stock(sku)— checks inventory for a product.
Here's the shared part — the data and the tool executor. Both approaches below reuse this exact method; only the Claude plumbing changes.
using System.Text.Json;
// Aurora Coffee Co.'s "database" — in memory, so this runs with zero setup.
var orders = new Dictionary<string, Order>
{
["A-1001"] = new("A-1001", "shipped", "2026-07-26"),
["A-1002"] = new("A-1002", "processing", "2026-07-29"),
};
var stock = new Dictionary<string, int> { ["ETH-250"] = 42, ["COL-1KG"] = 0 };
// Your tools are ordinary C#. Claude never runs this — it only asks you to.
string RunTool(string name, IReadOnlyDictionary<string, JsonElement> args) => name switch
{
"get_order_status" =>
orders.TryGetValue(args["order_id"].GetString()!, out var o)
? $"Order {o.Id}: {o.Status}, ETA {o.Eta}."
: "No order found with that id.",
"check_stock" =>
stock.TryGetValue(args["sku"].GetString()!, out var qty)
? $"SKU {args["sku"].GetString()}: {qty} units in stock."
: "Unknown SKU.",
_ => $"Unknown tool: {name}",
};
sealed record Order(string Id, string Status, string Eta);
Notice RunTool takes the tool inputs as a dictionary of JsonElement. We parse them — we never string-match the raw JSON Claude sends. That matters, and we'll come back to it.
Approach 1 — Raw HttpClient (see the protocol)
No SDK, no dependencies — just HttpClient and System.Net.Http.Json. This is the version that teaches you what's really going on the wire.
First, describe the tools as JSON Schema. This is the exact array that ships in the request:
var tools = new object[]
{
new
{
name = "get_order_status",
description = "Look up the status and ETA of a customer order by its id.",
input_schema = new
{
type = "object",
properties = new
{
order_id = new { type = "string", description = "Order id, e.g. A-1001" },
},
required = new[] { "order_id" },
},
},
new
{
name = "check_stock",
description = "Check how many units of a product SKU are currently in stock.",
input_schema = new
{
type = "object",
properties = new
{
sku = new { type = "string", description = "Product SKU, e.g. ETH-250" },
},
required = new[] { "sku" },
},
},
};
The description fields are load-bearing — they're how Claude decides when to reach for each tool. Write them like you'd write a docstring for a junior dev, not like a variable name.
Now the client and the agent loop. We keep a growing list of messages and keep calling until Claude stops asking for tools:
using System.Net.Http.Json;
var http = new HttpClient { BaseAddress = new Uri("https://api.anthropic.com") };
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"));
http.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web);
// The conversation grows as we go. Start with the user's question.
var messages = new List<object>
{
new { role = "user", content = "Is order A-1001 shipped, and do you still have ETH-250 in stock?" },
};
while (true)
{
var request = new { model = "claude-opus-4-8", max_tokens = 1024, tools, messages };
var response = await http.PostAsJsonAsync("/v1/messages", request, json);
response.EnsureSuccessStatusCode();
var reply = await response.Content.ReadFromJsonAsync<JsonElement>();
var stopReason = reply.GetProperty("stop_reason").GetString();
var content = reply.GetProperty("content");
// Echo Claude's whole turn back — tool_use blocks included — or the next
// request won't line up with the tool_result we're about to send.
messages.Add(new { role = "assistant", content });
if (stopReason != "tool_use")
{
foreach (var block in content.EnumerateArray())
if (block.GetProperty("type").GetString() == "text")
Console.WriteLine(block.GetProperty("text").GetString());
break;
}
// Claude asked for one or more tools. Run them all; collect every result.
var results = new List<object>();
foreach (var block in content.EnumerateArray())
{
if (block.GetProperty("type").GetString() != "tool_use") continue;
var name = block.GetProperty("name").GetString()!;
var toolInput = block.GetProperty("input").Deserialize<Dictionary<string, JsonElement>>(json)!;
results.Add(new
{
type = "tool_result",
tool_use_id = block.GetProperty("id").GetString(),
content = RunTool(name, toolInput),
});
}
// All the tool_results go back in ONE user message.
messages.Add(new { role = "user", content = results });
}
Three details that will bite you if you skip them:
-
Echo the assistant turn back verbatim, tool_use blocks and all. The follow-up request must contain the original
tool_usefor everytool_result, matched bytool_use_id. - Return every result in a single user message. If Claude asks for two tools and you split the answers across two messages, it quietly learns to stop asking for tools in parallel.
-
Name your loop variable something other than
args. Top-level statements already define an implicitargs, and the compiler will not be shy about it. Ask me how I know.
That's a complete agent in one file — and you can see every byte that crosses the wire. Hand-rolling the JSON gets old fast, though, which is exactly why the SDK exists.
Approach 2 — The Anthropic .NET SDK (the short version)
Same agent, far less plumbing. Add the package:
dotnet add package Anthropic
Tool definitions become typed objects. InputSchema.Type is set to "object" for you — don't set it yourself:
using Anthropic;
using Anthropic.Models.Messages;
using System.Text.Json;
AnthropicClient client = new(); // reads ANTHROPIC_API_KEY
List<ToolUnion> tools =
[
new Tool
{
Name = "get_order_status",
Description = "Look up the status and ETA of a customer order by its id.",
InputSchema = new()
{
Properties = new Dictionary<string, JsonElement>
{
["order_id"] = JsonSerializer.SerializeToElement(
new { type = "string", description = "Order id, e.g. A-1001" }),
},
Required = ["order_id"],
},
},
new Tool
{
Name = "check_stock",
Description = "Check how many units of a product SKU are currently in stock.",
InputSchema = new()
{
Properties = new Dictionary<string, JsonElement>
{
["sku"] = JsonSerializer.SerializeToElement(
new { type = "string", description = "Product SKU, e.g. ETH-250" }),
},
Required = ["sku"],
},
},
];
The loop is the same idea, now with typed blocks. One quirk worth knowing: the SDK gives you response blocks (TextBlock, ToolUseBlock) but the request wants param blocks (TextBlockParam, ToolUseBlockParam), and there's no .ToParam() shortcut — you rebuild them. It's a few lines, and it keeps the type system honest:
List<MessageParam> messages =
[
new() { Role = Role.User, Content = "Is order A-1001 shipped, and do you still have ETH-250 in stock?" },
];
while (true)
{
var response = await client.Messages.Create(new MessageCreateParams
{
Model = Model.ClaudeOpus4_8,
MaxTokens = 1024,
Tools = tools,
Messages = messages,
});
List<ContentBlockParam> assistant = [];
List<ContentBlockParam> results = [];
foreach (var block in response.Content)
{
if (block.TryPickText(out TextBlock? text))
{
assistant.Add(new TextBlockParam { Text = text.Text });
}
else if (block.TryPickToolUse(out ToolUseBlock? call))
{
assistant.Add(new ToolUseBlockParam { ID = call.ID, Name = call.Name, Input = call.Input });
results.Add(new ToolResultBlockParam { ToolUseID = call.ID, Content = RunTool(call.Name, call.Input) });
}
}
messages.Add(new() { Role = Role.Assistant, Content = assistant });
if (response.StopReason != "tool_use")
{
foreach (var t in response.Content.Select(b => b.Value).OfType<TextBlock>())
Console.WriteLine(t.Text);
break;
}
messages.Add(new() { Role = Role.User, Content = results });
}
Notice RunTool didn't change at all — ToolUseBlock.Input is already an IReadOnlyDictionary<string, JsonElement>, so our executor drops right in.
Let the SDK drive the loop
If you don't want to write the loop yourself, the SDK ships a beta tool runner. You give each tool its schema and the code that runs it (a Run callback), and the runner handles the whole call-execute-continue cycle for you:
using Anthropic;
using Anthropic.Helpers.Beta;
using Anthropic.Models.Beta.Messages;
using System.Text.Json;
// Bundle each tool's schema with the code that answers it.
BetaRunnableTool MakeTool(string name, string description, string prop, string hint) => new()
{
Name = name,
Definition = new BetaTool
{
Name = name,
Description = description,
InputSchema = new()
{
Properties = new Dictionary<string, JsonElement>
{
[prop] = JsonSerializer.SerializeToElement(new { type = "string", description = hint }),
},
Required = [prop],
},
},
Run = (call, ct) => Task.FromResult<BetaToolResultBlockParamContent>(RunTool(call.Name, call.Input)),
};
List<IBetaRunnableTool> runnable =
[
MakeTool("get_order_status", "Look up the status and ETA of an order by id.", "order_id", "e.g. A-1001"),
MakeTool("check_stock", "Check units in stock for a product SKU.", "sku", "e.g. ETH-250"),
];
var runner = client.Beta.Messages.ToolRunner(
new MessageCreateParams
{
Model = "claude-opus-4-8",
MaxTokens = 1024,
Messages = [new() { Role = Role.User, Content = "Is A-1001 shipped, and is ETH-250 in stock?" }],
},
runnable);
await foreach (BetaMessage message in runner)
foreach (var block in message.Content)
if (block.TryPickText(out var text))
Console.WriteLine(text.Text);
No manual loop, no block reconstruction — the runner calls your Run callbacks and keeps the conversation going until Claude is done. Whichever version you run, the output is the same:
> Is order A-1001 shipped, and do you still have ETH-250 in stock?
Order A-1001 has shipped and is on track to arrive 2026-07-26. And yes — the
Ethiopia beans (ETH-250) are in stock, with 42 units available.
Two tools, one question, and Claude figured out on its own that it needed to call both.
When to use tool use — and when not to
Tool use is the right call when the model needs something it can't have from text alone: live or private data (your database, an internal API, the filesystem) or the ability to take an action (send an email, create a ticket, refund an order). If the answer is genuinely locked inside your systems, tool use is how the model gets to it.
It's the wrong call when a single prompt would do. If you just need JSON out of a blob of text, reach for structured outputs, not a tool loop — you'll skip the extra round trips. Not every problem is an agent; some are just a well-shaped prompt.
Between the two implementations: go raw HttpClient when you're learning the protocol, want zero dependencies, or need total control over the loop — approval gates before a tool fires, custom logging, human-in-the-loop review. Go SDK for everything you'd actually ship: typed models, less boilerplate, and the tool runner when you just want the loop handled. And if cost matters more than raw capability, swap claude-opus-4-8 for claude-sonnet-5 — same code, cheaper runs.
Rule of thumb: prompt first, tool use when the model needs to reach into your world, an agent loop only when it needs to reach in more than once.
Key Takeaways
- The loop is the whole trick, not magic — declare tools, Claude requests one, you run it, you return the result, repeat until it answers. Everything else is plumbing.
- You own execution, and that's the point — Claude only asks to run a tool; your code decides whether and how. The security boundary never leaves your side.
-
Parse tool inputs, never string-match them — read the JSON into typed values (
JsonElement/ a dictionary). Matching against the raw serialized string is a bug waiting for a Unicode escape. -
Native teaches the protocol, the SDK ships it —
HttpClientshows every byte and gives you full control; the Anthropic SDK gives you typed blocks and a tool runner that drives the loop. - Start simple — a plain prompt beats an agent for most tasks. Add tool use when the model needs live data or has to do something, and an agent loop only when one tool call isn't enough.



Top comments (0)