Tools and schemas
Everything on this page happens at compile time. There is no reflection in the tool pipeline, which is why a full agent publishes as a ~1.5 MB Native AOT binary.
What the generator produces
For each [ClaudeTool] method it emits, on the containing partial type:
- a
{MethodName}Toolproperty of typeToolDefinition, - the tool's JSON Schema, derived from the parameter list,
- a typed dispatcher that binds the model's JSON arguments to your parameters and converts the return value back to text.
The wire name is the method name in snake_case (GetWeather → get_weather); override it
with [ClaudeTool(Name = "...")].
Descriptions
Claude picks tools — and argument values — from descriptions, so they matter. Emissary reads them from your XML doc comments, with the attribute as an override:
/// <summary>Refunds a payment for an order.</summary>
/// <param name="orderId">The order id, e.g. ORD-7.</param>
/// <param name="amount">The refund amount in the order's currency.</param>
[ClaudeTool]
public static string RefundPayment(string orderId, double amount) => /* ... */;
Important
Doc comments are only visible to the generator when the project sets
<GenerateDocumentationFile>true</GenerateDocumentationFile>. Without it you'll get EMS001
(no tool description) even though the comment is right there.
Supported parameter types
string, bool, int, long, double, enums, arrays of those, and records/classes composed
of them (nested, recursively). CancellationToken is injected and excluded from the schema.
Optional parameters with defaults become non-required schema properties.
[ClaudeTool(Description = "Books a room.")]
public static string Book(Reservation reservation, bool notify = true) => /* ... */;
public sealed record Reservation(string Room, string CheckInDate, string[] Guests);
Anything else is a build error (EMS002) naming the offending parameter — not a runtime
serialization failure.
When the model sends the wrong type
Models do occasionally send "3" where a number belongs, or invent an enum value. The generated
binder validates every value against the declared type and returns an error tool result naming what
was expected and what arrived, so the model can correct itself on the next turn instead of the run
dying on an exception:
Tool 'add' argument 'left' must be a whole number between -2147483648 and 2147483647,
but the value was the string "one".
Tool 'convert' argument 'unit' must be one of: Celsius, Fahrenheit. Received "Kelvin".
Tool 'join' argument 'parts' item 2 must be a string, but the value was the number 3.
Object members report as Object 'Address' member 'zip', and an unknown enum value always lists the
permitted set — the model usually gets it right on the retry. Two details worth knowing: an explicit
null is treated the same as an absent property (so an optional parameter falls back to its
default, and a required one reports as missing), and a number too large for its parameter type is
rejected rather than silently saturated to infinity.
Tools with dependencies
A tool method may be an instance method, which is how a tool reaches a DbContext, an
HttpClient, or anything else from your container — no service locator, no static state:
public sealed partial class OrderTools(IOrderRepository orders, ILogger<OrderTools> logger)
{
/// <summary>Looks up the status of an order.</summary>
/// <param name="orderId">The order id.</param>
[ClaudeTool]
public async Task<string> LookupOrder(string orderId, CancellationToken cancellationToken)
{
logger.LogInformation("Looking up {OrderId}", orderId);
return await orders.StatusOfAsync(orderId, cancellationToken);
}
}
The generated {Method}Tool is then an instance property rather than a static one, and its
handler is bound to that instance:
var tools = scope.ServiceProvider.GetRequiredService<OrderTools>();
options.Tools.Add(tools.LookupOrderTool);
Build the options inside the scope whose lifetime the dependencies expect — per request for scoped services — exactly as you would for any other class that holds them. The definition is built once per instance and cached, so adding it to options costs nothing after the first access. Everything else is identical to a static tool: same schema generation, doc-comment descriptions, safety flags, diagnostics, and zero reflection.
A tool and its CompensatedBy target must both be static or both be instance methods, since the
generated definition references the compensator directly; a mismatch is EMS012.
Capping tool output
A tool that returns far more than you expected — a table dump, a whole log file — quietly consumes your context window and your token budget. Cap it at the source:
/// <summary>Dumps recent rows from a table.</summary>
/// <param name="table">The table name.</param>
[ClaudeTool(MaxResultLength = 8_000)]
public static string DumpTable(string table) => /* ... */;
Output past the cap is replaced with a short notice telling the model that data was withheld and
to narrow its request, so it adapts instead of reasoning over a silently truncated answer. A
negative cap is a build error (EMS011); 0 (the default) means no cap.
Tools from an OpenAPI specification
Emissary.OpenApi reads a specification and hands back the tools it describes, so an agent can
drive an API you wrote no tool code for:
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new("Bearer", apiKey);
var set = OpenApiTools.FromSpec(File.ReadAllText("petstore.json"), http);
Console.WriteLine(set.ToText()); // what was generated, and what was skipped
foreach (var tool in set.Tools) options.Tools.Add(tool);
Parameters, request bodies and $refs become one self-contained input schema per operation;
references are expanded because Claude's tool schemas cannot carry them, and a type that contains
itself degrades to an open object rather than recursing forever.
The safety posture is derived, not configured
This is the part worth understanding. A specification already says which operations read and which
ones write, and that is exactly what Emissary's taint tracking needs. So reads become
Untrusted and writes become Privileged, which produces an invariant nobody had to write down:
once the agent has read a response body — content someone else authored — it cannot write back through the same API.
Point this at a stranger's specification and the resulting agent is injection-safe by construction.
Both defaults are overridable (ReadsAreUntrusted, WritesArePrivileged), and WritePolicy puts
every mutating operation behind an IToolAuthorizer check.
Selecting operations
A large public specification will happily generate four hundred tools, and a prompt carrying four
hundred tool schemas is expensive and worse at choosing. So MaxTools defaults to 64 and
exceeding it throws at startup rather than at spend time:
var options = new OpenApiToolOptions { Prefix = "stripe_", MaxResultLength = 8_000 };
options.Tags.Add("Customers"); // or OperationIds for exact selection
What it will not do
| Authentication | The HttpClient's job. Header parameters are never exposed to the model, because a model that can set headers can set Authorization. An operation that requires a header parameter is skipped and reported. |
| YAML | Convert it to JSON first. |
Remote $ref |
Refused, not fetched: reading a specification should not make network calls. Bundle the document first. |
| Non-JSON bodies | Skipped and reported — multipart/form-data uploads are not modelled. |
Nothing is skipped silently; set.Skipped names every operation and why, which is the difference
between a debugging session and a line of startup output.
A non-success response is returned to the model as content (HTTP 404 Not Found: …) rather than
raised as a tool failure, because a refusal is an answer: 404 no such customer is information the
agent should act on.
Structured outputs
Mark a record [ClaudeSchema] to get a compile-time strict schema
(additionalProperties: false at every level), then let the API guarantee the shape:
/// <summary>A triaged support ticket.</summary>
/// <param name="Title">A short, specific title.</param>
/// <param name="Severity">How urgent the issue is.</param>
[ClaudeSchema]
public sealed partial record TicketTriage(string Title, Severity Severity, string[] Tags);
var options = new AgentOptions().WithOutput<TicketTriage>();
var triage = await agent.RunAsync("Triage this report: …", MyJsonContext.Default.TicketTriage);
Deserialization goes through System.Text.Json source generation, so the whole path — schema, API, result — is reflection-free.
Streaming a structured answer
For a UI that should fill in as the model writes, stream the value instead of awaiting it:
await foreach (var partial in agent.StreamAsync("Triage this…", MyJsonContext.Default.TicketTriage))
{
Render(partial); // title appears first, then severity, then tags
}
Emissary completes the partially received JSON on each chunk, so you get a real
TicketTriage rather than raw text. Chunks that are not yet deserializable — a half-written
property name, a partially spelled enum — are skipped.
Important
A partial is a progress snapshot, not a validated value: properties that have not arrived
yet are null/default even where the type declares them non-nullable, and a string may
hold only the part received so far. Guard against nulls when rendering, and use the final item
(or RunAsync<T>) when you need the whole answer.
Web search is single-turn, for now
options.WebSearch turns on Claude's server-side search, which runs inside one turn — Emissary
never dispatches it. A turn's text, thinking, and tool_use blocks are assembled into the
conversation; the blocks a search produces (server_tool_use, web_search_tool_result, and the
citations attached to text) are not yet modeled, so they do not survive into the recorded
conversation.
What that means in practice:
- A single-turn "search and answer" works normally.
- On a later turn the model no longer sees its own search results, so it may search again.
- Citations are unavailable.
- A turn made up only of server-side blocks ends the run rather than sending an empty message the
API would reject — reported as
Pausedwhen the API paused the turn.
Round-tripping these blocks means getting eight content-block shapes and their request-side equivalents exactly right; a wrong shape makes every follow-up turn fail. Per ADR 0008 that has to be verified against the live API rather than inferred, so it is tracked as work rather than guessed at.
Diagnostics
| Id | Severity | Meaning |
|---|---|---|
EMS001 |
Warning | Tool has no description |
EMS002 |
Error | Unsupported parameter or member type |
EMS003 |
Info | Tool parameter has no <param> description |
EMS004 |
Error | Containing type is not partial |
EMS006 |
Error | Unsupported return type |
EMS007 |
Error | Generic tool method or generic containing type |
EMS008 |
Error | [ClaudeSchema] type is not schema-representable |
EMS009 |
Error | CompensatedBy target is not a [ClaudeTool] on the same type |
EMS010 |
Warning | [AuthorizeTool] without [ClaudeTool] — the policy would be ignored |
EMS011 |
Error | MaxResultLength is negative |
EMS012 |
Error | A tool and its CompensatedBy target differ in static-ness |
EMS005 is retired: it used to reject instance tool methods, which are now
supported. The id is not reused.
Composing agents
An agent can be handed to another agent as a single tool, and safety composes with it: if the sub-agent can read untrusted content, its tool is marked untrusted too, so the parent's taint rules still apply across the boundary.
var researcher = new ClaudeAgent(researchOptions);
parentOptions.Tools.Add(researcher.AsTool("researcher", "Delegates research questions."));
Handing a conversation off
AsTool delegates a question — the sub-agent answers and control returns to the caller. A
handoff delegates the conversation: the target takes over and produces the final answer, running
the history it inherits under its own system prompt, tools, and contracts. This is the shape behind
a triage agent that routes to specialists.
var billing = new ClaudeAgent(billingOptions);
var triageOptions = new AgentOptions { SystemPrompt = "Route the customer to the right team." };
triageOptions.Handoffs.Add(new HandoffTarget("billing", billing, "Charges, refunds and invoices."));
var triage = new ClaudeAgent(triageOptions, transport);
Each target becomes a handoff_to_{name} tool the model can call, described by the target's
Description. When the model calls one, the run emits an AgentHandoffEvent and the target
continues from there; the AgentResult you get back is the target's.
Three properties are worth knowing:
- Taint crosses the boundary. The target inherits the source agent's guard state, so a conversation that read untrusted content before the transfer still cannot reach privileged tools after it. The transfer is not a laundering step — see Safety.
- Usage accumulates. Token counts and planned effects span the whole run, not just the agent that finished it.
- Chains terminate.
AgentOptions.MaxHandoffs(default 3) caps the transfers in one run. At the cap the transfer tool still executes as an ordinary tool, so the model sees an acknowledgment and answers itself rather than the run failing.
Handoffs is read when the agent is constructed, so every target must already exist — handoff
graphs are acyclic. To send a conversation back to a generalist, give that generalist the final say
by making it the last agent in the chain rather than a cycle.